├── .gitignore ├── .travis.yml ├── AUTHORS.rst ├── LICENSE ├── NOTICE ├── README.rst ├── config.ini.sample ├── dev-requirements.txt ├── requirements.txt ├── setup.py ├── taiga_ncurses ├── __init__.py ├── api │ └── client.py ├── cli.py ├── config.py ├── controllers │ ├── __init__.py │ ├── auth.py │ ├── backlog.py │ ├── base.py │ ├── issues.py │ ├── milestones.py │ ├── projects.py │ └── wiki.py ├── core.py ├── data.py ├── executor.py └── ui │ ├── signals.py │ ├── views │ ├── __init__.py │ ├── auth.py │ ├── backlog.py │ ├── base.py │ ├── issues.py │ ├── milestones.py │ ├── projects.py │ └── wiki.py │ └── widgets │ ├── __init__.py │ ├── auth.py │ ├── backlog.py │ ├── generic.py │ ├── issues.py │ ├── milestones.py │ ├── mixins.py │ ├── projects.py │ ├── utils.py │ └── wiki.py └── tests ├── __init__.py ├── controllers ├── test_backlog_controller.py ├── test_issues_controller.py ├── test_milestones_controller.py ├── test_project_controller.py └── test_wiki_controller.py ├── factories.py ├── fixtures.py ├── test_config.py ├── test_core.py ├── test_executor.py └── test_views.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .coverage 26 | .tox 27 | nosetests.xml 28 | 29 | # Translations 30 | *.mo 31 | 32 | # Vim 33 | *.swp 34 | 35 | # Libreoffice 36 | *.odg# 37 | 38 | # Tags 39 | tags 40 | 41 | # Virtualenv 42 | .venv 43 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.3" 4 | # dependencies 5 | install: 6 | - pip install -r dev-requirements.txt --use-mirrors 7 | script: 8 | - coverage run --source=taiga_ncurses --omit='*tests*,*cli.py,*api*' -m py.test 9 | notifications: 10 | email: 11 | recipients: 12 | - bameda@dbarragan.com 13 | on_success: change 14 | on_failure: change 15 | after_success: 16 | - coveralls 17 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | The PRIMARY AUTHORS are: 2 | 3 | - David Barragán 4 | - Alejandro Gómez 5 | 6 | Special thanks to Kaleidos Open Source S.L for provice time for Taiga 7 | development. 8 | 9 | And here is an inevitably incomplete list of MUCH-APPRECIATED CONTRIBUTORS -- 10 | people who have submitted patches, reported bugs, added translations, helped 11 | answer newbie questions, and generally made Taiga that much better: 12 | 13 | - ... 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | This product is licensed to you under the Apache License, Version 2.0 (the "License"). 2 | You may not use this product except in compliance with the License. 3 | 4 | This product may include a number of subcomponents with 5 | separate copyright notices and license terms. Your use of the source 6 | code for the these subcomponents is subject to the terms and 7 | conditions of the subcomponent's license. 8 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | taiga-ncurses 2 | ================= 3 | 4 | .. image:: http://kaleidos.net/static/img/badge.png 5 | :target: http://kaleidos.net/community/greenmine/ 6 | .. image:: https://taiga.io/media/support/attachments/article-22/banner-gh.png 7 | :target: https://taiga.io 8 | .. image:: https://travis-ci.org/taigaio/taiga-ncurses.svg?branch=master 9 | :target: https://travis-ci.org/taigaio/taiga-ncurses 10 | .. image:: https://coveralls.io/repos/taigaio/taiga-ncurses/badge.svg?branch=master 11 | :target: https://coveralls.io/r/taigaio/taiga-ncurses?branch=master 12 | 13 | 14 | 15 | A NCurses client for Taiga. 16 | 17 | Project status 18 | -------------- 19 | 20 | Currently on design phases: This project was a proof of concept to try to create a curses client 21 | for Taiga in the 6th `PiWeek`_. It isn't finished yet and currently it isn't 22 | feature complete. You can see some screenshots at https://github.com/taigaio/taiga-ncurses/issues/4#issuecomment-57717386 23 | 24 | Setup development environment 25 | ----------------------------- 26 | 27 | Just execute these commands in your virtualenv(wrapper): 28 | 29 | .. code-block:: 30 | 31 | $ pip install -r dev-requirements.txt 32 | $ python setup.py develop 33 | $ py.test # to run the tests 34 | $ taiga-ncurses # to run the app 35 | 36 | 37 | Obviously you need the `taiga backend`_ and, if you don't fancy living in darkness, 38 | you can use the `taiga web client`_, sometimes. :P 39 | 40 | Note: taiga-ncurses only runs with python 3.3+. 41 | 42 | Community 43 | --------- 44 | 45 | Taiga has a `mailing list`_. Feel free to join it and ask any questions you may have. 46 | 47 | To subscribe for announcements of releases, important changes and so on, please follow 48 | `@taigaio`_ on Twitter. 49 | 50 | .. _taiga backend: https://github.com/kaleidos/taiga-back 51 | .. _taiga web client: https://github.com/kaleidos/taiga-front 52 | .. _mailing list: http://groups.google.com/d/forum/taigaio 53 | .. _@taigaio: https://twitter.com/taigaio 54 | .. _PiWeek: http://piweek.com 55 | -------------------------------------------------------------------------------- /config.ini.sample: -------------------------------------------------------------------------------- 1 | [main] 2 | [[host]] 3 | scheme = http 4 | domain = localhost 5 | port = 8000 6 | [[site]] 7 | domain = localhost 8 | palette = default 9 | [[keys]] 10 | quit = q 11 | debug = D 12 | backlog = B 13 | admin = A 14 | milestone = M 15 | projects = P 16 | issues = I 17 | wiki = W 18 | [backlog] 19 | [[keys]] 20 | move_to_milestone = m 21 | edit = e 22 | update_order = w 23 | reload = r 24 | increase_priority = K 25 | decrease_priority = J 26 | delete = delete 27 | create = n 28 | create_in_bulk = N 29 | help = ? 30 | [milestone] 31 | [[keys]] 32 | edit = e 33 | create_task = n 34 | change_to_milestone = m 35 | delete = delete 36 | create_user_story = N 37 | reload = r 38 | help = ? 39 | [issues] 40 | [[keys]] 41 | edit = e 42 | reload = r 43 | create = n 44 | delete = delete 45 | help = ? 46 | filters = f 47 | [palettes] 48 | [[default]] 49 | progressbar-complete-red = white, dark red 50 | progressbar-smooth = dark green, dark gray 51 | default = white, default 52 | popup-submit-button = white, dark green 53 | projects-button = black, dark green 54 | progressbar-normal-red = black, dark gray, standout 55 | green-bg = white, dark green 56 | popup-selected = dark cyan, black 57 | info = white, dark blue 58 | error = white, dark red 59 | progressbar-smooth-red = dark red, dark gray 60 | submit-button = white, dark green 61 | footer = black, black 62 | progressbar-complete = white, dark green 63 | cyan = dark cyan, default 64 | footer-error = dark red, black 65 | password-editor = "light red,underline", black 66 | popup = white, dark gray 67 | popup-cancel-button = black, light gray 68 | yellow = yellow, default 69 | magenta = light magenta, default 70 | focus-header = black, dark green 71 | popup-text-red = dark red, dark gray 72 | account-button = black, dark green 73 | progressbar-normal = black, dark gray, standout 74 | footer-info = light blue, black 75 | inactive-tab = white, default 76 | focus = black, dark cyan 77 | green = dark green, default 78 | red = dark red, default 79 | active-tab = white, dark blue 80 | help-button = white, black 81 | popup-editor = "white,underline", dark gray 82 | popup-section-title = "white,underline,bold", dark gray 83 | editor = "white,underline", black 84 | popup-text-magenta = light magenta, dark gray 85 | cancel-button = black, light gray 86 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | coveralls==0.4.4 3 | pytest==2.6.4 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Urwid 2 | #git+https://github.com/wardi/urwid.git 3 | urwid==1.3.0 4 | 5 | # Extra deps 6 | requests==2.5.0 7 | x256==0.0.3 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | A text interface to Taiga. 5 | """ 6 | 7 | from __future__ import print_function 8 | import sys 9 | if sys.version_info[0] < 3 or sys.version_info[1] < 3: 10 | print("Sorry, taiga-ncurses needs python >= 3.3", file=sys.stderr) 11 | sys.exit(-1) 12 | 13 | 14 | from taiga_ncurses import __name__, __description__, __version__ 15 | 16 | from setuptools import setup, find_packages 17 | 18 | REQUIREMENTS = [ 19 | "requests==2.5.0", 20 | "urwid>=1.3.0", 21 | "x256==0.0.3" 22 | ] 23 | 24 | 25 | NAME = __name__ 26 | DESCRIPTION = __description__ 27 | VERSION = "{0}.{1}".format(*__version__) 28 | 29 | setup(name=NAME, 30 | version=VERSION, 31 | description=DESCRIPTION, 32 | packages=find_packages(), 33 | entry_points={ 34 | "console_scripts": ["taiga-ncurses = taiga_ncurses.cli:main"] 35 | }, 36 | classifiers=[ 37 | "Development Status :: 3 - Alpha", 38 | "Environment :: Console :: Curses", 39 | "Intended Audience :: End Users/Desktop", 40 | "Operating System :: POSIX :: Linux", 41 | "Operating System :: MacOS", 42 | "Programming Language :: Python :: 3.3", 43 | ], 44 | install_requires=REQUIREMENTS,) 45 | -------------------------------------------------------------------------------- /taiga_ncurses/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | taiga_ncurses 5 | ~~~~~~~~~~~~~ 6 | """ 7 | 8 | __name__ = "taiga_ncurses" 9 | __description__ = "A text interface to Taiga" 10 | __version__ = (0, 0) 11 | -------------------------------------------------------------------------------- /taiga_ncurses/api/client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | taiga_ncurses.api.client 5 | ~~~~~~~~~~~~~~~~~~~~~~~~ 6 | """ 7 | 8 | from urllib.parse import urljoin 9 | import json 10 | 11 | import requests 12 | 13 | 14 | class BaseClient: 15 | """ The base class for an API client. """ 16 | 17 | BASE_HEADERS = { 18 | "content-type": "application/json; charset: utf8", 19 | "X-DISABLE-PAGINATION": "true", 20 | } 21 | 22 | def __init__(self, host): 23 | self._host = host 24 | self._headers = self.BASE_HEADERS 25 | self.last_error = {} 26 | 27 | def _get(self, url, params): 28 | response = requests.get(url, params=params, headers=self._headers) 29 | data = json.loads(response.content.decode()) 30 | 31 | if response.status_code == 200: 32 | return data 33 | 34 | self.last_error = { 35 | "status_code": response.status_code, 36 | "detail": data.get("detail", "") 37 | } 38 | return None 39 | 40 | def _post(self, url, data_dict, params): 41 | rdata = json.dumps(data_dict) 42 | 43 | response = requests.post(url, data=rdata, params=params, headers=self._headers) 44 | 45 | if response.status_code in [200, 201]: 46 | data = json.loads(response.content.decode()) 47 | return data 48 | elif response.status_code == 204: # No content 49 | return True 50 | else: 51 | data = json.loads(response.content.decode()) 52 | 53 | self.last_error = { 54 | "status_code": response.status_code, 55 | "detail": data.get("detail", "") 56 | } 57 | return None 58 | 59 | def _patch(self, url, data_dict, params): 60 | rdata = json.dumps(data_dict) 61 | 62 | response = requests.patch(url, data=rdata, params=params, headers=self._headers) 63 | data = json.loads(response.content.decode()) 64 | 65 | if response.status_code == 200: 66 | return data 67 | 68 | self.last_error = { 69 | "status_code": response.status_code, 70 | "detail": data.get("detail", "") 71 | } 72 | return None 73 | 74 | def _delete(self, url, params): 75 | response = requests.delete(url, params=params, headers=self._headers) 76 | 77 | if response.status_code == 204: 78 | return True 79 | 80 | data = json.loads(response.content.decode()) 81 | 82 | self.last_error = { 83 | "status_code": response.status_code, 84 | "detail": data.get("detail", "") 85 | } 86 | return None 87 | 88 | 89 | class TaigaClient(BaseClient): 90 | """ A Taiga Api Client. 91 | 92 | >>> from taiga_ncurses.api.client import * 93 | >>> api = TaigaClient("http://localhost:8000") 94 | >>> api.login("admin", "123123") 95 | {...} 96 | >>> api.get_projects() 97 | [...] 98 | >>> api.get_project(1) 99 | {...} 100 | >>> api.get_user_stories(params={'project': 1}) 101 | [...] 102 | >>> api.get_issue(1234) 103 | None 104 | >>> api.last_error 105 | {'detail': 'Not found', 'status_code': 404} 106 | 107 | """ 108 | 109 | URLS = { 110 | "auth": "/api/v1/auth", 111 | "users": "/api/v1/users", 112 | "user": "/api/v1/users/{}", 113 | "projects": "/api/v1/projects", 114 | "project": "/api/v1/projects/{}", 115 | "project-stats": "/api/v1/projects/{}/stats", 116 | "project-issues-stats": "/api/v1/projects/{}/issues_stats", 117 | "milestones": "/api/v1/milestones", 118 | "milestone": "/api/v1/milestones/{}", 119 | "milestone-stats": "/api/v1/milestones/{}/stats", 120 | "user_stories": "/api/v1/userstories", 121 | "user_stories_bulk_create": "/api/v1/userstories/bulk_create", 122 | "user_stories_bulk_update_order": "/api/v1/userstories/bulk_update_order", 123 | "user_story": "/api/v1/userstories/{}", 124 | "tasks": "/api/v1/tasks", 125 | "task": "/api/v1/tasks/{}", 126 | "issues": "/api/v1/issues", 127 | "issue": "/api/v1/issues/{}", 128 | "wiki_pages": "/api/v1/wiki", 129 | "wiki_page": "/api/v1/wiki/{}", 130 | } 131 | 132 | # AUTHENTICATIONS 133 | def __init__(self, host): 134 | super().__init__(host) 135 | 136 | @property 137 | def is_authenticated(self): 138 | return "Authorization" in self._headers 139 | 140 | def set_auth_token(self, auth_token): 141 | self._headers["Authorization"] = "Bearer {}".format(auth_token) 142 | 143 | def login(self, username, password, params={}): 144 | url = urljoin(self._host, self.URLS.get("auth")) 145 | data_dict = { 146 | "username": username, 147 | "password": password, 148 | "type": "normal" 149 | } 150 | data = self._post(url, data_dict, params) 151 | 152 | if data and "auth_token" in data: 153 | self.set_auth_token(data["auth_token"]) 154 | return data 155 | 156 | def logout(self): 157 | self._headers = self.BASE_HEADERS 158 | return True 159 | 160 | # USER 161 | 162 | def get_users(self, params={}): 163 | url = urljoin(self._host, self.URLS.get("users")) 164 | return self._get(url, params) 165 | 166 | def update_user(self, id, data_dict={}, params={}): 167 | url = urljoin(self._host, self.URLS.get("user").format(id)) 168 | return self._patch(url, data_dict, params) 169 | 170 | def get_user(self, id, params={}): 171 | url = urljoin(self._host, self.URLS.get("user").format(id)) 172 | return self._get(url, params) 173 | 174 | # PROJECT 175 | 176 | def get_projects(self, params={}): 177 | url = urljoin(self._host, self.URLS.get("projects")) 178 | return self._get(url, params) 179 | 180 | def create_project(self, data_dict={}, params={}): 181 | url = urljoin(self._host, self.URLS.get("projects")) 182 | return self._post(url, data_dict, params) 183 | 184 | def update_project(self, id, data_dict={}, params={}): 185 | url = urljoin(self._host, self.URLS.get("project").format(id)) 186 | return self._patch(url, data_dict, params) 187 | 188 | def get_project(self, id, params={}): 189 | url = urljoin(self._host, self.URLS.get("project").format(id)) 190 | return self._get(url, params) 191 | 192 | def delete_project(self, id, params={}): 193 | url = urljoin(self._host, self.URLS.get("project").format(id)) 194 | return self._delete(url, params) 195 | 196 | def get_project_stats(self, id, params={}): 197 | url = urljoin(self._host, self.URLS.get("project-stats").format(id)) 198 | return self._get(url, params) 199 | 200 | def get_project_issues_stats(self, id, params={}): 201 | url = urljoin(self._host, self.URLS.get("project-issues-stats").format(id)) 202 | return self._get(url, params) 203 | 204 | # MILESTONE 205 | 206 | def get_milestones(self, params={}): 207 | url = urljoin(self._host, self.URLS.get("milestones")) 208 | return self._get(url, params) 209 | 210 | def create_milestone(self, data_dict={}, params={}): 211 | url = urljoin(self._host, self.URLS.get("milestones")) 212 | return self._post(url, data_dict, params) 213 | 214 | def update_milestone(self, id, data_dict={}, params={}): 215 | url = urljoin(self._host, self.URLS.get("milestone").format(id)) 216 | return self._patch(url, data_dict, params) 217 | 218 | def get_milestone(self, id, params={}): 219 | url = urljoin(self._host, self.URLS.get("milestone").format(id)) 220 | return self._get(url, params) 221 | 222 | def delete_milestone(self, id, params={}): 223 | url = urljoin(self._host, self.URLS.get("milestone").format(id)) 224 | return self._delete(url, params) 225 | 226 | def get_milestone_stats(self, id, params={}): 227 | url = urljoin(self._host, self.URLS.get("milestone-stats").format(id)) 228 | return self._get(url, params) 229 | 230 | 231 | # USER STORY 232 | 233 | def get_user_stories(self, params={}): 234 | url = urljoin(self._host, self.URLS.get("user_stories")) 235 | return self._get(url, params) 236 | 237 | def update_user_stories_order(self, data_dict={}, params={}): 238 | url = urljoin(self._host, self.URLS.get("user_stories_bulk_update_order")) 239 | return self._post(url, data_dict, params) 240 | 241 | def create_user_stories_in_bulk(self, data_dict={}, params={}): 242 | url = urljoin(self._host, self.URLS.get("user_stories_bulk_create")) 243 | return self._post(url, data_dict, params) 244 | 245 | def create_user_story(self, data_dict={}, params={}): 246 | url = urljoin(self._host, self.URLS.get("user_stories")) 247 | return self._post(url, data_dict, params) 248 | 249 | def update_user_story(self, id, data_dict={}, params={}): 250 | url = urljoin(self._host, self.URLS.get("user_story").format(id)) 251 | return self._patch(url, data_dict, params) 252 | 253 | def get_user_story(self, id, params={}): 254 | url = urljoin(self._host, self.URLS.get("user_story").format(id)) 255 | return self._get(url, params) 256 | 257 | def delete_user_story(self, id, params={},): 258 | url = urljoin(self._host, self.URLS.get("user_story").format(id)) 259 | return self._delete(url, params) 260 | 261 | # TASK 262 | 263 | def get_tasks(self, params={}): 264 | url = urljoin(self._host, self.URLS.get("tasks")) 265 | return self._get(url, params) 266 | 267 | def create_task(self, data_dict={}, params={}): 268 | url = urljoin(self._host, self.URLS.get("tasks")) 269 | return self._post(url, data_dict, params) 270 | 271 | def update_task(self, id, data_dict={}, params={}): 272 | url = urljoin(self._host, self.URLS.get("task").format(id)) 273 | return self._patch(url, data_dict, params) 274 | 275 | def get_task(self, id, params={}): 276 | url = urljoin(self._host, self.URLS.get("task").format(id)) 277 | return self._get(url, params) 278 | 279 | def delete_task(self, id, params={}): 280 | url = urljoin(self._host, self.URLS.get("task").format(id)) 281 | return self._delete(url, params) 282 | 283 | # ISSUE 284 | 285 | def get_issues(self, params={}): 286 | url = urljoin(self._host, self.URLS.get("issues")) 287 | return self._get(url, params) 288 | 289 | def create_issue(self, data_dict={}, params={}): 290 | url = urljoin(self._host, self.URLS.get("issues")) 291 | return self._post(url, data_dict, params) 292 | 293 | def update_issue(self, id, data_dict={}, params={}): 294 | url = urljoin(self._host, self.URLS.get("issue").format(id)) 295 | return self._patch(url, data_dict, params) 296 | 297 | def get_issue(self, id, params={}): 298 | url = urljoin(self._host, self.URLS.get("issue").format(id)) 299 | return self._get(url, params) 300 | 301 | def delete_issue(self, id, params={}): 302 | url = urljoin(self._host, self.URLS.get("issue").format(id)) 303 | return self._delete(url, params) 304 | 305 | # WIKI PAGE 306 | 307 | def get_wiki_pages(self, params={}): 308 | url = urljoin(self._host, self.URLS.get("wiki_pages")) 309 | return self._get(url, params) 310 | 311 | def create_wiki_page(self, data_dict={}, params={}): 312 | url = urljoin(self._host, self.URLS.get("wiki_pages")) 313 | return self._post(url, data_dict, params) 314 | 315 | def update_wiki_page(self, id, data_dict={}, params={}): 316 | url = urljoin(self._host, self.URLS.get("wiki_page").format(id)) 317 | return self._patch(url, data_dict, params) 318 | 319 | def get_wiki_page(self, id, params={}): 320 | url = urljoin(self._host, self.URLS.get("wiki_page").format(id)) 321 | return self._get(url, params) 322 | 323 | def delete_wiki_page(self, id, params={}): 324 | url = urljoin(self._host, self.URLS.get("wiki_page").format(id)) 325 | return self._delete(url, params) 326 | -------------------------------------------------------------------------------- /taiga_ncurses/cli.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | taiga_ncurses.cli 5 | ~~~~~~~~~~~~~~~~~ 6 | """ 7 | 8 | from taiga_ncurses.api.client import TaigaClient 9 | from taiga_ncurses.core import TaigaCore 10 | from taiga_ncurses.config import settings 11 | from taiga_ncurses.executor import Executor 12 | 13 | 14 | def main(): 15 | settings.load() 16 | client = TaigaClient(settings.host) 17 | if settings.data.auth.token: 18 | client.set_auth_token(settings.data.auth.token) 19 | executor = Executor(client) 20 | program = TaigaCore(executor, settings, authenticated=settings.data.auth.token) 21 | program.run() 22 | -------------------------------------------------------------------------------- /taiga_ncurses/config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | taiga_ncurses.config 5 | ~~~~~~~~~~~~~~~~~~~~ 6 | """ 7 | 8 | import os 9 | import urllib 10 | 11 | 12 | ########################################################### 13 | # Default config settings 14 | ########################################################### 15 | 16 | DEFAULT_CONFIG_DIR = os.path.join(os.environ["HOME"], ".taiga-ncurses") 17 | DEFAULT_CONFIG_FILE = os.path.join(DEFAULT_CONFIG_DIR, "config.ini") 18 | 19 | # Default color palette 20 | 21 | DEFAULT_PALETTE = { 22 | "default": ("white", "default"), 23 | "editor": ("white,underline", "black", ), 24 | "password-editor": ("light red,underline", "black"), 25 | "submit-button": ("white", "dark green"), 26 | "cancel-button": ("black", "light gray"), 27 | "popup": ("white","dark gray"), 28 | "popup-section-title": ("white,underline,bold", "dark gray"), 29 | "popup-editor": ("white,underline", "dark gray"), 30 | "popup-submit-button": ("white", "dark green"), 31 | "popup-cancel-button": ("black", "light gray"), 32 | "popup-selected": ("dark cyan", "black"), 33 | "popup-text-magenta": ("light magenta", "dark gray"), 34 | "popup-text-red": ("dark red", "dark gray"), 35 | "error": ("white", "dark red"), 36 | "info": ("white", "dark blue"), 37 | "green": ("dark green", "default"), 38 | "red": ("dark red", "default"), 39 | "yellow": ("yellow", "default"), 40 | "cyan": ("dark cyan", "default"), 41 | "magenta": ("light magenta", "default"), 42 | "green-bg": ("white", "dark green"), 43 | "projects-button": ("black", "dark green"), 44 | "account-button": ("black", "dark green"), 45 | "help-button": ("white", "black"), 46 | "footer": ("black", "black"), 47 | "footer-error": ("dark red", "black"), 48 | "footer-info": ("light blue", "black"), 49 | "active-tab": ("white", "dark blue"), 50 | "inactive-tab": ("white", "default"), 51 | "focus": ("black", "dark cyan"), 52 | "focus-header": ("black", "dark green"), 53 | "progressbar-normal": ("black", "dark gray", "standout"), 54 | "progressbar-complete": ("white", "dark green"), 55 | "progressbar-smooth": ("dark green","dark gray"), 56 | "progressbar-normal-red": ("black", "dark gray", "standout"), 57 | "progressbar-complete-red": ("white", "dark red"), 58 | "progressbar-smooth-red": ("dark red","dark gray") 59 | } 60 | 61 | # Default keyboard shortcut 62 | 63 | MAIN_KEYS = { 64 | "quit": "q", 65 | "debug": "D", 66 | "projects": "P", 67 | "backlog": "B", 68 | "milestone": "M", 69 | "issues": "I", 70 | "wiki": "W", 71 | "admin": "A" 72 | } 73 | 74 | BACKLOG_KEYS = { 75 | "create": "n", 76 | "create_in_bulk": "N", 77 | "edit": "e", 78 | "delete": "delete", 79 | "update_order": "w", 80 | "move_to_milestone": "m", 81 | "increase_priority": "K", 82 | "decrease_priority": "J", 83 | "reload": "r", 84 | "help":"?" 85 | } 86 | 87 | MILESTONE_KEYS = { 88 | "create_user_story": "N", 89 | "create_task": "n", 90 | "edit": "e", 91 | "delete": "delete", 92 | "change_to_milestone": "m", 93 | "reload": "r", 94 | "help": "?" 95 | } 96 | 97 | ISSUES_KEYS = { 98 | "create": "n", 99 | "edit": "e", 100 | "delete": "delete", 101 | "filters": "f", 102 | "reload": "r", 103 | "help": "?" 104 | } 105 | 106 | # Default settings 107 | 108 | DEFAULTS = { 109 | "main": { 110 | "host": { 111 | "scheme": "http", 112 | "domain": "localhost", 113 | "port": "8000", 114 | }, 115 | "site": { 116 | "domain": "localhost", 117 | }, 118 | "palette": "default", 119 | "keys": MAIN_KEYS 120 | }, 121 | "backlog": { 122 | "keys": BACKLOG_KEYS 123 | }, 124 | "milestone": { 125 | "keys": MILESTONE_KEYS 126 | }, 127 | "issues": { 128 | "keys": ISSUES_KEYS 129 | }, 130 | "palettes": { 131 | "default": DEFAULT_PALETTE 132 | }, 133 | "auth": { 134 | "token": None, 135 | } 136 | } 137 | 138 | class ConfigData: 139 | def __init__(self, data): 140 | super().__setattr__('_data', data) 141 | 142 | def __dir__(self): 143 | return self._data.keys() 144 | 145 | def __getattr__(self, name): 146 | if name not in self._data: 147 | raise AttributeError 148 | 149 | if isinstance(self._data[name], dict): 150 | return self.__class__(self._data[name]) 151 | 152 | return self._data[name] 153 | 154 | def __setattr__(self, name, value): 155 | self._data[name] = value 156 | 157 | def __delattr__(self, name): 158 | if name not in self._data: 159 | raise AttributeError 160 | 161 | del self._data[name] 162 | 163 | def items(self): 164 | return self._data.items() 165 | 166 | 167 | class ConfiguratioManager: 168 | def __init__(self, config_file=DEFAULT_CONFIG_FILE): 169 | self.config_file = config_file 170 | self.data = ConfigData(DEFAULTS.copy()) 171 | 172 | def load(self): 173 | # TODO 174 | pass 175 | 176 | def save(self): 177 | # TODO 178 | pass 179 | 180 | @property 181 | def host(self) -> str: 182 | scheme = self.data.main.host.scheme 183 | assert scheme in ("http", "https") 184 | domain = urllib.parse.quote(self.data.main.host.domain) 185 | try: 186 | port = ":{}".format(self.data.main.host.port) 187 | except AttributeError: 188 | port = "" 189 | return "{scheme}://{domain}{port}".format(scheme=scheme, 190 | domain=domain, 191 | port=port) 192 | 193 | @property 194 | def palette(self): 195 | palette_name = self.data.main.palette 196 | try: 197 | palette_dict = getattr(self.data.palettes, palette_name) 198 | except AttributeError: 199 | palette_dict = DEFAULT_PALETTE 200 | return [(k,) + tuple(v) for k, v in palette_dict.items()] 201 | 202 | 203 | settings = ConfiguratioManager() 204 | -------------------------------------------------------------------------------- /taiga_ncurses/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | taiga_ncurses.controllers 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | """ 7 | 8 | from . import ( 9 | auth, projects, backlog, milestones, issues, wiki 10 | ) 11 | -------------------------------------------------------------------------------- /taiga_ncurses/controllers/auth.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | taiga_ncurses.controllers.auth 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | """ 7 | 8 | from taiga_ncurses.ui import signals 9 | 10 | from . import base 11 | 12 | 13 | class LoginController(base.Controller): 14 | def __init__(self, view, executor, state_machine): 15 | self.view = view 16 | self.executor = executor 17 | self.state_machine = state_machine 18 | 19 | signals.connect(self.view.login_button, "click", lambda _: self.handle_login_request()) 20 | 21 | def handle_login_request(self): 22 | self.view.notifier.clear_msg() 23 | 24 | username = self.view.username 25 | password = self.view.password 26 | if not username or not password: 27 | self.view.notifier.error_msg("Enter your username and password") 28 | return 29 | 30 | logged_in_f = self.executor.login(username, password) 31 | logged_in_f.add_done_callback(self.handle_login_response) 32 | 33 | def handle_login_response(self, future): 34 | response = future.result() 35 | if response is None: 36 | self.view.notifier.error_msg("Login error") 37 | self.state_machine.refresh() 38 | else: 39 | self.view.notifier.info_msg("Login succesful!") 40 | self.state_machine.logged_in(response) 41 | -------------------------------------------------------------------------------- /taiga_ncurses/controllers/backlog.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | taiga_ncurses.controllers.backlog 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | """ 7 | 8 | from concurrent.futures import wait 9 | import functools, copy 10 | 11 | from taiga_ncurses.config import settings 12 | from taiga_ncurses.ui import signals 13 | 14 | from . import base 15 | 16 | 17 | class ProjectBacklogSubController(base.Controller): 18 | def __init__(self, view, executor, state_machine): 19 | self.view = view 20 | self.executor = executor 21 | self.state_machine = state_machine 22 | 23 | self.view.user_stories.on_user_story_status_change = self.handle_change_user_story_status_request 24 | self.view.user_stories.on_user_story_points_change = self.handle_change_user_story_points_request 25 | 26 | def handle(self, key): 27 | if key == settings.data.backlog.keys.create: 28 | self.new_user_story() 29 | if key == settings.data.backlog.keys.create_in_bulk: 30 | self.new_user_stories_in_bulk() 31 | elif key == settings.data.backlog.keys.edit: 32 | self.edit_user_story() 33 | elif key == settings.data.backlog.keys.delete: 34 | self.delete_user_story() 35 | elif key == settings.data.backlog.keys.increase_priority: 36 | self.move_current_us_up() 37 | elif key == settings.data.backlog.keys.decrease_priority: 38 | self.move_current_us_down() 39 | elif key == settings.data.backlog.keys.update_order: 40 | self.update_user_stories_order() 41 | elif key == settings.data.backlog.keys.move_to_milestone: 42 | self.move_user_story_to_milestone() 43 | elif key == settings.data.backlog.keys.reload: 44 | self.load() 45 | elif key == settings.data.backlog.keys.help: 46 | self.help_info() 47 | else: 48 | super().handle(key) 49 | 50 | def load(self): 51 | self.state_machine.transition(self.state_machine.PROJECT_BACKLOG) 52 | 53 | self.view.notifier.info_msg("Fetching Stats and User stories") 54 | 55 | project_stats_f = self.executor.project_stats(self.view.project) 56 | project_stats_f.add_done_callback(self.handle_project_stats) 57 | 58 | user_stories_f = self.executor.unassigned_user_stories(self.view.project) 59 | user_stories_f.add_done_callback(self.handle_user_stories) 60 | 61 | futures = (project_stats_f, user_stories_f) 62 | futures_completed_f = self.executor.pool.submit(lambda : wait(futures, 10)) 63 | futures_completed_f.add_done_callback(functools.partial(self.when_backlog_info_fetched, 64 | info_msg="Project stats and user stories " 65 | "fetched", 66 | error_msg="Failed to fetch project data")) 67 | 68 | def new_user_story(self): 69 | self.view.open_user_story_form() 70 | 71 | signals.connect(self.view.user_story_form.cancel_button, "click", 72 | lambda _: self.cancel_user_story_form()) 73 | signals.connect(self.view.user_story_form.save_button, "click", 74 | lambda _: self.handler_create_user_story_request()) 75 | 76 | def edit_user_story(self): 77 | user_story = self.view.user_stories.widget.get_focus().user_story 78 | self.view.open_user_story_form(user_story=user_story) 79 | 80 | signals.connect(self.view.user_story_form.cancel_button, "click", 81 | lambda _: self.cancel_user_story_form()) 82 | signals.connect(self.view.user_story_form.save_button, "click", 83 | lambda _: self.handler_edit_user_story_request(user_story)) 84 | 85 | def cancel_user_story_form(self): 86 | self.view.close_user_story_form() 87 | 88 | def new_user_stories_in_bulk(self): 89 | self.view.open_user_stories_in_bulk_form() 90 | 91 | signals.connect(self.view.user_stories_in_bulk_form.cancel_button, "click", 92 | lambda _: self.cancel_user_stories_in_bulk_form()) 93 | signals.connect(self.view.user_stories_in_bulk_form.save_button, "click", 94 | lambda _: self.handler_create_user_stories_in_bulk_request()) 95 | 96 | def cancel_user_stories_in_bulk_form(self): 97 | self.view.close_user_stories_in_bulk_form() 98 | 99 | def delete_user_story(self): 100 | user_story = self.view.user_stories.widget.get_focus().user_story 101 | 102 | uss_delete_f = self.executor.delete_user_story(user_story) 103 | uss_delete_f.add_done_callback(self.handler_delete_user_story_response) 104 | 105 | def move_current_us_up(self): 106 | current_focus = self.user_stories.index(self.view.user_stories.widget.get_focus().user_story) 107 | 108 | if current_focus > 0 and len(self.user_stories) > 2: 109 | current_us = self.user_stories[current_focus] 110 | self.user_stories[current_focus] = self.user_stories[current_focus - 1] 111 | self.user_stories[current_focus - 1] = current_us 112 | 113 | self.view.notifier.info_msg("Moved User story #{} up".format(current_us["ref"])) 114 | 115 | self.view.user_stories.populate(self.user_stories, self.project_stats, set_focus=current_us) 116 | 117 | def move_current_us_down(self): 118 | current_focus = self.user_stories.index(self.view.user_stories.widget.get_focus().user_story) 119 | 120 | if current_focus < len(self.user_stories) - 1 and len(self.user_stories) > 2: 121 | current_us = self.user_stories[current_focus] 122 | self.user_stories[current_focus] = self.user_stories[current_focus + 1] 123 | self.user_stories[current_focus + 1] = current_us 124 | 125 | self.view.notifier.info_msg("Moved User story #{} down".format(current_us["ref"])) 126 | 127 | self.view.user_stories.populate(self.user_stories, self.project_stats, set_focus=current_us) 128 | 129 | def update_user_stories_order(self): 130 | uss_post_f = self.executor.update_user_stories_order(self.user_stories, self.view.project) 131 | uss_post_f.add_done_callback(self.handler_update_user_stories_order_response) 132 | 133 | def move_user_story_to_milestone(self): 134 | user_story = self.view.user_stories.widget.get_focus().user_story 135 | self.view.open_milestones_selector_popup(user_story=user_story) 136 | 137 | signals.connect(self.view.milestone_selector_popup.cancel_button, "click", 138 | lambda _: self.cancel_milestone_selector_popup()) 139 | 140 | for option in self.view.milestone_selector_popup.options: 141 | signals.connect(option, "click", functools.partial( 142 | self.handler_move_user_story_to_milestone_request, user_story=user_story)) 143 | 144 | def cancel_milestone_selector_popup(self): 145 | self.view.close_milestone_selector_popup() 146 | 147 | def help_info(self): 148 | self.view.open_help_popup() 149 | 150 | signals.connect(self.view.help_popup.close_button, "click", 151 | lambda _: self.close_help_info()) 152 | 153 | def close_help_info(self): 154 | self.view.close_help_popup() 155 | 156 | def handle_project_stats(self, future): 157 | self.project_stats = future.result() 158 | 159 | def handle_user_stories(self, future): 160 | self.user_stories = future.result() 161 | 162 | def when_backlog_info_fetched(self, future_with_results, info_msg=None, error_msg=None): 163 | done, not_done = future_with_results.result() 164 | 165 | if len(done) == 2: 166 | # FIXME TODO: Moved to handle_project_stats and fixed populate method to update the content 167 | # of the main widget instead of replace the widget 168 | self.view.stats.populate(self.project_stats) 169 | self.view.user_stories.populate(self.user_stories, self.project_stats) 170 | 171 | if info_msg: 172 | self.view.notifier.info_msg(info_msg) 173 | 174 | self.state_machine.refresh() 175 | else: 176 | # TODO retry failed operationsi 177 | if error_msg: 178 | self.view.notifier.error_msg(error_msg) 179 | 180 | def handler_create_user_story_request(self): 181 | data = self.view.get_user_story_form_data() 182 | 183 | if not data.get("subject", None): 184 | self.view.notifier.error_msg("Subject is required") 185 | else: 186 | us_post_f = self.executor.create_user_story(data) 187 | us_post_f.add_done_callback(self.handler_create_user_story_response) 188 | 189 | def handler_create_user_story_response(self, future): 190 | response = future.result() 191 | 192 | if response is None: 193 | self.view.notifier.error_msg("Create error") 194 | else: 195 | self.view.notifier.info_msg("Create successful!") 196 | self.view.close_user_story_form() 197 | 198 | project_stats_f = self.executor.project_stats(self.view.project) 199 | project_stats_f.add_done_callback(self.handle_project_stats) 200 | 201 | user_stories_f = self.executor.unassigned_user_stories(self.view.project) 202 | user_stories_f.add_done_callback(self.handle_user_stories) 203 | 204 | futures = (project_stats_f, user_stories_f) 205 | futures_completed_f = self.executor.pool.submit(lambda : wait(futures, 10)) 206 | futures_completed_f.add_done_callback(self.when_backlog_info_fetched) 207 | 208 | def handler_edit_user_story_request(self, user_story): 209 | data = self.view.get_user_story_form_data() 210 | 211 | if not data.get("subject", None): 212 | self.view.notifier.error_msg("Subject is required") 213 | else: 214 | us_patch_f = self.executor.update_user_story(user_story, data) 215 | us_patch_f.add_done_callback(self.handler_edit_user_story_response) 216 | 217 | def handler_edit_user_story_response(self, future): 218 | response = future.result() 219 | 220 | if response is None: 221 | self.view.notifier.error_msg("Edit error") 222 | else: 223 | self.view.notifier.info_msg("Edit successful!") 224 | self.view.close_user_story_form() 225 | 226 | project_stats_f = self.executor.project_stats(self.view.project) 227 | project_stats_f.add_done_callback(self.handle_project_stats) 228 | 229 | user_stories_f = self.executor.unassigned_user_stories(self.view.project) 230 | user_stories_f.add_done_callback(self.handle_user_stories) 231 | 232 | futures = (project_stats_f, user_stories_f) 233 | futures_completed_f = self.executor.pool.submit(lambda : wait(futures, 10)) 234 | futures_completed_f.add_done_callback(self.when_backlog_info_fetched) 235 | 236 | def handler_create_user_stories_in_bulk_request(self): 237 | data = self.view.get_user_stories_in_bulk_form_data() 238 | 239 | if not data.get("bulkStories", None): 240 | self.view.notifier.error_msg("Subjects are required") 241 | else: 242 | us_post_f = self.executor.create_user_stories_in_bulk(data) 243 | us_post_f.add_done_callback(self.handler_create_user_stories_in_bulk_response) 244 | 245 | def handler_create_user_stories_in_bulk_response(self, future): 246 | response = future.result() 247 | 248 | if response is None: 249 | self.view.notifier.error_msg("Create error") 250 | else: 251 | self.view.notifier.info_msg("Create successful!") 252 | self.view.close_user_stories_in_bulk_form() 253 | 254 | project_stats_f = self.executor.project_stats(self.view.project) 255 | project_stats_f.add_done_callback(self.handle_project_stats) 256 | 257 | user_stories_f = self.executor.unassigned_user_stories(self.view.project) 258 | user_stories_f.add_done_callback(self.handle_user_stories) 259 | 260 | futures = (project_stats_f, user_stories_f) 261 | futures_completed_f = self.executor.pool.submit(lambda : wait(futures, 10)) 262 | futures_completed_f.add_done_callback(self.when_backlog_info_fetched) 263 | 264 | def handler_delete_user_story_response(self, future): 265 | response = future.result() 266 | 267 | if response is None: 268 | self.view.notifier.error_msg("Error deleting user_story") 269 | else: 270 | self.view.notifier.info_msg("Delete user story") 271 | 272 | project_stats_f = self.executor.project_stats(self.view.project) 273 | project_stats_f.add_done_callback(self.handle_project_stats) 274 | 275 | user_stories_f = self.executor.unassigned_user_stories(self.view.project) 276 | user_stories_f.add_done_callback(self.handle_user_stories) 277 | 278 | futures = (project_stats_f, user_stories_f) 279 | futures_completed_f = self.executor.pool.submit(lambda : wait(futures, 10)) 280 | futures_completed_f.add_done_callback(self.when_backlog_info_fetched) 281 | 282 | def handler_update_user_stories_order_response(self, future): 283 | response = future.result() 284 | 285 | if response is None: 286 | self.view.notifier.error_msg("Error moving user_story") 287 | 288 | user_stories_f = self.executor.unassigned_user_stories(self.view.project) 289 | user_stories_f.add_done_callback(self.handle_user_stories) 290 | else: 291 | self.view.notifier.info_msg("Save user stories") 292 | 293 | project_stats_f = self.executor.project_stats(self.view.project) 294 | project_stats_f.add_done_callback(self.handle_project_stats) 295 | 296 | user_stories_f = self.executor.unassigned_user_stories(self.view.project) 297 | user_stories_f.add_done_callback(self.handle_user_stories) 298 | 299 | futures = (project_stats_f, user_stories_f) 300 | futures_completed_f = self.executor.pool.submit(lambda : wait(futures, 10)) 301 | futures_completed_f.add_done_callback(self.when_backlog_info_fetched) 302 | 303 | def handler_move_user_story_to_milestone_request(self, selected_option, user_story=None): 304 | data = {"milestone": selected_option.milestone["id"]} 305 | 306 | us_patch_f = self.executor.update_user_story(user_story, data) 307 | us_patch_f.add_done_callback(self.handler_move_user_story_to_milestone_response) 308 | 309 | self.cancel_milestone_selector_popup() 310 | 311 | def handler_move_user_story_to_milestone_response(self, future): 312 | response = future.result() 313 | 314 | if response is None: 315 | self.view.notifier.error_msg("Error moving user story to milestone") 316 | else: 317 | self.view.notifier.info_msg("Moved user story to milestone succesful!") 318 | 319 | project_stats_f = self.executor.project_stats(self.view.project) 320 | project_stats_f.add_done_callback(self.handle_project_stats) 321 | 322 | user_stories_f = self.executor.unassigned_user_stories(self.view.project) 323 | user_stories_f.add_done_callback(self.handle_user_stories) 324 | 325 | futures = (project_stats_f, user_stories_f) 326 | futures_completed_f = self.executor.pool.submit(lambda : wait(futures, 10)) 327 | futures_completed_f.add_done_callback(self.when_backlog_info_fetched) 328 | 329 | def handle_change_user_story_status_request(self, combo, item, state, user_data=None): 330 | data = {"status": item.value} 331 | user_story = user_data 332 | 333 | user_story_patch_f = self.executor.update_user_story(user_story, data) 334 | user_story_patch_f.add_done_callback(self.handle_change_user_story_status_response) 335 | 336 | def handle_change_user_story_status_response(self, future): 337 | response = future.result() 338 | 339 | if response is None: 340 | self.view.notifier.error_msg("Change user story status with errors") 341 | # TODO: Select old value 342 | else: 343 | self.view.notifier.info_msg("Change user story status successful!") 344 | 345 | project_stats_f = self.executor.project_stats(self.view.project) 346 | project_stats_f.add_done_callback(self.handle_project_stats) 347 | 348 | user_stories_f = self.executor.unassigned_user_stories(self.view.project) 349 | user_stories_f.add_done_callback(self.handle_user_stories) 350 | 351 | futures = (project_stats_f, user_stories_f) 352 | futures_completed_f = self.executor.pool.submit(lambda : wait(futures, 10)) 353 | futures_completed_f.add_done_callback(self.when_backlog_info_fetched) 354 | 355 | def handle_change_user_story_points_request(self, combo, item, state, user_data=None): 356 | user_story, role_id = user_data 357 | data = {"points": {role_id: item.value}} 358 | 359 | user_story_patch_f = self.executor.update_user_story(user_story, data) 360 | user_story_patch_f.add_done_callback(self.handle_change_user_story_points_response) 361 | 362 | def handle_change_user_story_points_response(self, future): 363 | response = future.result() 364 | 365 | if response is None: 366 | self.view.notifier.error_msg("Change user story points with errors") 367 | # TODO: Select old value 368 | else: 369 | self.view.notifier.info_msg("Change user story points successful!") 370 | 371 | project_stats_f = self.executor.project_stats(self.view.project) 372 | project_stats_f.add_done_callback(self.handle_project_stats) 373 | 374 | user_stories_f = self.executor.unassigned_user_stories(self.view.project) 375 | user_stories_f.add_done_callback(self.handle_user_stories) 376 | 377 | futures = (project_stats_f, user_stories_f) 378 | futures_completed_f = self.executor.pool.submit(lambda : wait(futures, 10)) 379 | futures_completed_f.add_done_callback(self.when_backlog_info_fetched) 380 | -------------------------------------------------------------------------------- /taiga_ncurses/controllers/base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | taiga_ncurses.controllers.base 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | """ 7 | 8 | 9 | class Controller: 10 | view = None 11 | 12 | def handle(self, key): 13 | return key 14 | -------------------------------------------------------------------------------- /taiga_ncurses/controllers/issues.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | taiga_ncurses.controllers.issues 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | """ 7 | 8 | from concurrent.futures import wait 9 | import functools 10 | 11 | from taiga_ncurses.config import settings 12 | from taiga_ncurses.ui import signals 13 | 14 | from . import base 15 | 16 | 17 | class ProjectIssuesSubController(base.Controller): 18 | def __init__(self, view, executor, state_machine): 19 | self.view = view 20 | self.executor = executor 21 | self.state_machine = state_machine 22 | 23 | signals.connect(self.view.issues_header.issue_button, "click", 24 | functools.partial(self.handle_order_by, "issue")) 25 | 26 | signals.connect(self.view.issues_header.status_button, "click", 27 | functools.partial(self.handle_order_by, "status")) 28 | 29 | signals.connect(self.view.issues_header.priority_button, "click", 30 | functools.partial(self.handle_order_by, "priority")) 31 | 32 | signals.connect(self.view.issues_header.severity_buttton, "click", 33 | functools.partial(self.handle_order_by, "severity")) 34 | 35 | signals.connect(self.view.issues_header.assigned_to_button, "click", 36 | functools.partial(self.handle_order_by, "assigned_to")) 37 | 38 | self.view.issues.on_issue_status_change = self.handle_change_issue_status_request 39 | self.view.issues.on_issue_priority_change = self.handle_change_issue_priority_request 40 | self.view.issues.on_issue_severity_change = self.handle_change_issue_severity_request 41 | self.view.issues.on_issue_assigned_to_change = self.handle_change_issue_assigned_to_request 42 | 43 | def handle(self, key): 44 | if key == settings.data.issues.keys.create: 45 | self.new_issue() 46 | elif key == settings.data.issues.keys.edit: 47 | self.edit_issue() 48 | elif key == settings.data.issues.keys.delete: 49 | self.delete_issue() 50 | elif key == settings.data.issues.keys.filters: 51 | self.filters() 52 | elif key == settings.data.issues.keys.reload: 53 | self.load() 54 | elif key == settings.data.issues.keys.help: 55 | self.help_info() 56 | else: 57 | super().handle(key) 58 | 59 | def load(self): 60 | self.state_machine.transition(self.state_machine.PROJECT_ISSUES) 61 | 62 | self.view.notifier.info_msg("Fetching Stats and Issues") 63 | 64 | issues_stats_f = self.executor.project_issues_stats(self.view.project) 65 | issues_stats_f.add_done_callback(self.handle_issues_stats) 66 | 67 | issues_f = self.executor.issues(self.view.project, filters=self.view.filters) 68 | issues_f.add_done_callback(self.handle_issues) 69 | 70 | futures = (issues_stats_f, issues_f) 71 | futures_completed_f = self.executor.pool.submit(lambda : wait(futures, 10)) 72 | futures_completed_f.add_done_callback(functools.partial(self.when_issues_info_fetched, 73 | info_msg="Stats and issues fetched", 74 | error_msg="Failed to fetch issues data")) 75 | 76 | def new_issue(self): 77 | self.view.open_issue_form() 78 | 79 | signals.connect(self.view.issue_form.cancel_button, "click", 80 | lambda _: self.cancel_issue_form()) 81 | signals.connect(self.view.issue_form.save_button, "click", 82 | lambda _: self.handle_create_issue_request()) 83 | 84 | def edit_issue(self): 85 | issue = self.view.issues.widget.get_focus()[0].issue 86 | self.view.open_issue_form(issue=issue) 87 | 88 | signals.connect(self.view.issue_form.cancel_button, "click", 89 | lambda _: self.cancel_issue_form()) 90 | signals.connect(self.view.issue_form.save_button, "click", 91 | lambda _: self.handle_edit_issue_request(issue)) 92 | 93 | def cancel_issue_form(self): 94 | self.view.close_issue_form() 95 | 96 | def delete_issue(self): 97 | issue = self.view.issues.widget.get_focus()[0].issue 98 | 99 | issue_delete_f = self.executor.delete_issue(issue) 100 | issue_delete_f.add_done_callback(self.handle_delete_issue_response) 101 | 102 | def filters(self): 103 | self.view.open_filters_popup() 104 | 105 | signals.connect(self.view.filters_popup.filter_button, "click", 106 | lambda _: self.apply_filters_from_filters_popup()) 107 | 108 | signals.connect(self.view.filters_popup.cancel_button, "click", 109 | lambda _: self.cancel_filters_popup()) 110 | 111 | def apply_filters_from_filters_popup(self): 112 | self.view.set_filters(self.view.get_filters_popup_data()) 113 | 114 | self.view.notifier.info_msg("Filter issues") 115 | self.cancel_filters_popup() 116 | 117 | issues_f = self.executor.issues(self.view.project, filters=self.view.filters) 118 | issues_f.add_done_callback(self.handle_refresh_issues) 119 | 120 | def cancel_filters_popup(self): 121 | self.view.close_filters_popup() 122 | 123 | def help_info(self): 124 | self.view.open_help_popup() 125 | 126 | signals.connect(self.view.help_popup.close_button, "click", 127 | lambda _: self.close_help_info()) 128 | 129 | def close_help_info(self): 130 | self.view.close_help_popup() 131 | 132 | def handle_issues_stats(self, future): 133 | self.issues_stats = future.result() 134 | 135 | def handle_issues(self, future): 136 | self.issues = future.result() 137 | 138 | def when_issues_info_fetched(self, future_with_results, info_msg=None, error_msg=None): 139 | done, not_done = future_with_results.result() 140 | if len(done) == 2: 141 | # FIXME TODO: Moved to handle_issues_stats, to handle_issues and fixed populate method to 142 | # update the content of the main widget instead of replace the widget 143 | self.view.stats.populate(self.issues_stats) 144 | self.view.issues.populate(self.issues) 145 | 146 | if info_msg: 147 | self.view.notifier.info_msg(info_msg) 148 | self.state_machine.refresh() 149 | else: 150 | # TODO retry failed operations 151 | if error_msg: 152 | self.view.notifier.error_msg(error_msg) 153 | 154 | def handle_create_issue_request(self): 155 | data = self.view.get_issue_form_data() 156 | 157 | if not data.get("subject", None): 158 | self.view.notifier.error_msg("Subject is required") 159 | else: 160 | us_post_f = self.executor.create_issue(data) 161 | us_post_f.add_done_callback(self.handle_create_issue_response) 162 | 163 | def handle_create_issue_response(self, future): 164 | response = future.result() 165 | 166 | if response is None: 167 | self.view.notifier.error_msg("Create error") 168 | else: 169 | self.view.notifier.info_msg("Create successful!") 170 | self.view.close_issue_form() 171 | 172 | issues_stats_f = self.executor.project_issues_stats(self.view.project) 173 | issues_stats_f.add_done_callback(self.handle_issues_stats) 174 | 175 | issues_f = self.executor.issues(self.view.project, filters=self.view.filters) 176 | issues_f.add_done_callback(self.handle_issues) 177 | 178 | futures = (issues_stats_f, issues_f) 179 | futures_completed_f = self.executor.pool.submit(lambda : wait(futures, 10)) 180 | futures_completed_f.add_done_callback(self.when_issues_info_fetched) 181 | 182 | def handle_edit_issue_request(self, issue): 183 | data = self.view.get_issue_form_data() 184 | 185 | if not data.get("subject", None): 186 | self.view.notifier.error_msg("Subject is required") 187 | else: 188 | issue_patch_f = self.executor.update_issue(issue, data) 189 | issue_patch_f.add_done_callback(self.handle_edit_issue_response) 190 | 191 | def handle_edit_issue_response(self, future): 192 | response = future.result() 193 | 194 | if response is None: 195 | self.view.notifier.error_msg("Edit error") 196 | else: 197 | self.view.notifier.info_msg("Edit successful!") 198 | self.view.close_issue_form() 199 | 200 | issues_stats_f = self.executor.project_issues_stats(self.view.project) 201 | issues_stats_f.add_done_callback(self.handle_issues_stats) 202 | 203 | issues_f = self.executor.issues(self.view.project, filters=self.view.filters) 204 | issues_f.add_done_callback(self.handle_issues) 205 | 206 | futures = (issues_stats_f, issues_f) 207 | futures_completed_f = self.executor.pool.submit(lambda : wait(futures, 10)) 208 | futures_completed_f.add_done_callback(self.when_issues_info_fetched) 209 | 210 | def handle_delete_issue_response(self, future): 211 | response = future.result() 212 | 213 | if response is None: 214 | self.view.notifier.error_msg("Error deleting issue") 215 | else: 216 | self.view.notifier.info_msg("Delete issue") 217 | 218 | issues_stats_f = self.executor.project_issues_stats(self.view.project) 219 | issues_stats_f.add_done_callback(self.handle_issues_stats) 220 | 221 | issues_f = self.executor.issues(self.view.project, filters=self.view.filters) 222 | issues_f.add_done_callback(self.handle_issues) 223 | 224 | futures = (issues_stats_f, issues_f) 225 | futures_completed_f = self.executor.pool.submit(lambda : wait(futures, 10)) 226 | futures_completed_f.add_done_callback(self.when_issues_info_fetched) 227 | 228 | def handle_order_by(self, param, button): 229 | self.view.notifier.info_msg("Ordered issues by {}".format(param)) 230 | issues_f = self.executor.issues(self.view.project, order_by=[param], filters=self.view.filters) 231 | issues_f.add_done_callback(self.handle_refresh_issues) 232 | 233 | def handle_change_issue_status_request(self, combo, item, state, user_data=None): 234 | data = {"status": item.value} 235 | issue = user_data 236 | 237 | issue_patch_f = self.executor.update_issue(issue, data) 238 | issue_patch_f.add_done_callback(self.handle_change_issue_status_response) 239 | 240 | def handle_change_issue_status_response(self, future): 241 | response = future.result() 242 | 243 | if response is None: 244 | self.view.notifier.error_msg("Change issue status error") 245 | else: 246 | self.view.notifier.info_msg("Change issue status successful!") 247 | 248 | issues_stats_f = self.executor.project_issues_stats(self.view.project) 249 | issues_stats_f.add_done_callback(self.handle_issues_stats) 250 | 251 | issues_f = self.executor.issues(self.view.project, filters=self.view.filters) 252 | issues_f.add_done_callback(self.handle_issues) 253 | 254 | futures = (issues_stats_f, issues_f) 255 | futures_completed_f = self.executor.pool.submit(lambda : wait(futures, 10)) 256 | futures_completed_f.add_done_callback(self.when_issues_info_fetched) 257 | 258 | def handle_change_issue_priority_request(self, combo, item, state, user_data=None): 259 | data = {"priority": item.value} 260 | issue = user_data 261 | 262 | issue_patch_f = self.executor.update_issue(issue, data) 263 | issue_patch_f.add_done_callback(self.handle_change_issue_priority_response) 264 | 265 | def handle_change_issue_priority_response(self, future): 266 | response = future.result() 267 | 268 | if response is None: 269 | self.view.notifier.error_msg("Change issue priority error") 270 | else: 271 | self.view.notifier.info_msg("Change issue priority successful!") 272 | 273 | issues_stats_f = self.executor.project_issues_stats(self.view.project) 274 | issues_stats_f.add_done_callback(self.handle_issues_stats) 275 | 276 | issues_f = self.executor.issues(self.view.project, filters=self.view.filters) 277 | issues_f.add_done_callback(self.handle_issues) 278 | 279 | futures = (issues_stats_f, issues_f) 280 | futures_completed_f = self.executor.pool.submit(lambda : wait(futures, 10)) 281 | futures_completed_f.add_done_callback(self.when_issues_info_fetched) 282 | 283 | def handle_change_issue_severity_request(self, combo, item, state, user_data=None): 284 | data = {"severity": item.value} 285 | issue = user_data 286 | 287 | issue_patch_f = self.executor.update_issue(issue, data) 288 | issue_patch_f.add_done_callback(self.handle_change_issue_severity_response) 289 | 290 | def handle_change_issue_severity_response(self, future): 291 | response = future.result() 292 | 293 | if response is None: 294 | self.view.notifier.error_msg("Change issue severity error") 295 | else: 296 | self.view.notifier.info_msg("Change issue severity successful!") 297 | 298 | issues_stats_f = self.executor.project_issues_stats(self.view.project) 299 | issues_stats_f.add_done_callback(self.handle_issues_stats) 300 | 301 | issues_f = self.executor.issues(self.view.project, filters=self.view.filters) 302 | issues_f.add_done_callback(self.handle_issues) 303 | 304 | futures = (issues_stats_f, issues_f) 305 | futures_completed_f = self.executor.pool.submit(lambda : wait(futures, 10)) 306 | futures_completed_f.add_done_callback(self.when_issues_info_fetched) 307 | 308 | def handle_change_issue_assigned_to_request(self, combo, item, state, user_data=None): 309 | data = {"assigned_to": item.value} 310 | issue = user_data 311 | 312 | issue_patch_f = self.executor.update_issue(issue, data) 313 | issue_patch_f.add_done_callback(self.handle_change_issue_assigned_to_response) 314 | 315 | def handle_change_issue_assigned_to_response(self, future): 316 | response = future.result() 317 | 318 | if response is None: 319 | self.view.notifier.error_msg("Change issue assignation error") 320 | else: 321 | self.view.notifier.info_msg("Change issue assignation successful!") 322 | 323 | issues_stats_f = self.executor.project_issues_stats(self.view.project) 324 | issues_stats_f.add_done_callback(self.handle_issues_stats) 325 | 326 | issues_f = self.executor.issues(self.view.project, filters=self.view.filters) 327 | issues_f.add_done_callback(self.handle_issues) 328 | 329 | futures = (issues_stats_f, issues_f) 330 | futures_completed_f = self.executor.pool.submit(lambda : wait(futures, 10)) 331 | futures_completed_f.add_done_callback(self.when_issues_info_fetched) 332 | 333 | def handle_refresh_issues(self, future): 334 | self.issues = future.result() 335 | if self.issues is not None: 336 | self.view.issues.populate(self.issues) 337 | self.state_machine.refresh() 338 | -------------------------------------------------------------------------------- /taiga_ncurses/controllers/projects.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | taiga_ncurses.controllers.projects 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | """ 7 | 8 | import functools 9 | 10 | from taiga_ncurses.config import settings 11 | from taiga_ncurses.ui import signals 12 | 13 | from . import base 14 | 15 | from .backlog import ProjectBacklogSubController 16 | from .milestones import ProjectMilestoneSubController 17 | from .issues import ProjectIssuesSubController 18 | from .wiki import ProjectWikiSubController 19 | 20 | 21 | class ProjectsController(base.Controller): 22 | def __init__(self, view, executor, state_machine): 23 | self.view = view 24 | self.executor = executor 25 | self.state_machine = state_machine 26 | 27 | projects_f = self.executor.projects() 28 | projects_f.add_done_callback(self.handle_projects_response) 29 | 30 | def handle_projects_response(self, future): 31 | projects = future.result() 32 | if projects is None: 33 | return # FIXME 34 | 35 | self.view.populate(projects) 36 | for b, p in zip(self.view.project_buttons, self.view.projects): 37 | signals.connect(b, "click", functools.partial(self.select_project, p)) 38 | 39 | self.state_machine.transition(self.state_machine.PROJECTS) 40 | 41 | def select_project(self, project, project_button): 42 | self.view.notifier.info_msg("Fetching info of project: {}".format(project["name"])) 43 | project_fetch_f = self.executor.project_detail(project) 44 | project_fetch_f.add_done_callback(self.handle_project_response) 45 | 46 | def handle_project_response(self, future): 47 | project = future.result() 48 | if project is None: 49 | self.view.notifier.error_msg("Failed to fetch info of project") 50 | else: 51 | self.state_machine.project_detail(project) 52 | 53 | 54 | class ProjectDetailController(base.Controller): 55 | def __init__(self, view, executor, state_machine): 56 | self.view = view 57 | self.executor = executor 58 | self.state_machine = state_machine 59 | 60 | # Subcontrollers 61 | self.backlog = ProjectBacklogSubController(self.view.backlog, executor, state_machine) 62 | self.sprint = ProjectMilestoneSubController(self.view.sprint, executor, state_machine) 63 | self.issues = ProjectIssuesSubController(self.view.issues, executor, state_machine) 64 | self.wiki = ProjectWikiSubController(self.view.wiki, executor, state_machine) 65 | self.admin = ProjectAdminSubController(self.view.backlog, executor, state_machine) 66 | 67 | self.subcontroller = self.backlog 68 | self.subcontroller.load() 69 | 70 | def handle(self, key): 71 | if key == settings.data.main.keys.backlog: 72 | self.view.backlog_view() 73 | self.subcontroller = self.backlog 74 | self.subcontroller.load() 75 | elif key == settings.data.main.keys.milestone: 76 | self.view.sprint_view() 77 | self.subcontroller = self.sprint 78 | self.subcontroller.load() 79 | elif key == settings.data.main.keys.issues: 80 | self.view.issues_view() 81 | self.subcontroller = self.issues 82 | self.subcontroller.load() 83 | elif key == settings.data.main.keys.wiki: 84 | self.view.wiki_view() 85 | self.subcontroller = self.wiki 86 | self.subcontroller.load() 87 | elif key == settings.data.main.keys.admin: 88 | self.view.admin_view() 89 | self.subcontroller = self.admin 90 | elif key == settings.data.main.keys.projects: 91 | self.state_machine.projects() 92 | else: 93 | self.subcontroller.handle(key) 94 | 95 | 96 | class ProjectAdminSubController(base.Controller): 97 | def __init__(self, view, executor, state_machine): 98 | self.view = view 99 | self.executor = executor 100 | self.state_machine = state_machine 101 | -------------------------------------------------------------------------------- /taiga_ncurses/controllers/wiki.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | taiga_ncurses.controllers.wiki 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | """ 7 | 8 | from concurrent.futures import wait 9 | 10 | from . import base 11 | 12 | 13 | class ProjectWikiSubController(base.Controller): 14 | def __init__(self, view, executor, state_machine): 15 | self.view = view 16 | self.executor = executor 17 | self.state_machine = state_machine 18 | 19 | self.view.wiki_page.on_wiki_page_change = self.handle_wiki_page_change 20 | 21 | def load(self): 22 | self.state_machine.transition(self.state_machine.PROJECT_WIKI) 23 | 24 | self.view.notifier.info_msg("Fetching Wiki") 25 | 26 | wiki_pages_f = self.executor.wiki_pages(self.view.project) 27 | wiki_pages_f.add_done_callback(self.handle_wiki_pages) 28 | 29 | futures = (wiki_pages_f,) 30 | futures_completed_f = self.executor.pool.submit(lambda : wait(futures, 10)) 31 | futures_completed_f.add_done_callback(self.when_wiki_pages_fetched) 32 | 33 | def handle_wiki_pages(self, future): 34 | self.wiki_pages = future.result() 35 | if self.wiki_pages is not None: 36 | if len(self.wiki_pages) > 0: 37 | self.view.wiki_page.populate(self.wiki_pages, self.wiki_pages[0]) 38 | self.state_machine.refresh() 39 | 40 | def when_wiki_pages_fetched(self, future_with_results): 41 | done, not_done = future_with_results.result() 42 | if len(done) == 1: 43 | self.view.notifier.info_msg("Wiki pages fetched") 44 | self.state_machine.refresh() 45 | else: 46 | # TODO retry failed operations 47 | self.view.notifier.error_msg("Failed to fetch wiki data") 48 | 49 | def handle_wiki_page_change(self, combo, item, state, user_data=None): 50 | wiki_page = item.value 51 | self.view.wiki_page.populate(self.wiki_pages, wiki_page) 52 | 53 | self.view.notifier.info_msg("Change to wiki page: '{}'".format(wiki_page["slug"])) 54 | -------------------------------------------------------------------------------- /taiga_ncurses/core.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | taiga_ncurses.core 5 | ~~~~~~~~~~~~~~~~~~ 6 | """ 7 | 8 | import functools 9 | from concurrent.futures import wait 10 | 11 | import urwid 12 | 13 | from taiga_ncurses.ui import views 14 | from taiga_ncurses import controllers 15 | from taiga_ncurses.config import settings 16 | 17 | 18 | class TaigaCore: 19 | def __init__(self, executor, settings, authenticated=False, draw=True): 20 | self.executor = executor 21 | self.settings = settings 22 | self.draw = draw 23 | 24 | if authenticated: 25 | self.state_machine = StateMachine(self, state=StateMachine.PROJECTS) 26 | self.controller = self._build_projects_controller() 27 | else: 28 | self.state_machine = StateMachine(self, state=StateMachine.LOGIN) 29 | self.controller = self._build_login_controller() 30 | 31 | # Main Loop 32 | self.loop = urwid.MainLoop(self.controller.view.widget, 33 | palette=self.settings.palette, 34 | unhandled_input=self.key_handler, 35 | handle_mouse=True, 36 | pop_ups=True) 37 | 38 | def run(self): 39 | self.loop.run() 40 | 41 | def key_handler(self, key): 42 | if key == settings.data.main.keys.quit: 43 | self.settings.save() 44 | raise urwid.ExitMainLoop 45 | elif key == settings.data.main.keys.debug: 46 | self.debug() 47 | else: 48 | return self.controller.handle(key) 49 | 50 | def debug(self): 51 | self.loop.screen.stop() 52 | import ipdb; ipdb.set_trace() 53 | self.loop.screen.start() 54 | 55 | def login_view(self): 56 | pass 57 | 58 | def projects_view(self): 59 | self.controller = self._build_projects_controller() 60 | self.transition() 61 | 62 | def project_view(self, project): 63 | self.controller = self._build_project_controller(project) 64 | self.transition() 65 | 66 | def transition(self): 67 | if self.draw: 68 | self.loop.widget = self.controller.view.widget 69 | self.loop.widget._invalidate() 70 | self.loop.draw_screen() 71 | 72 | def set_auth_config(self, auth_data): 73 | self.settings.data.auth.token = auth_data["auth_token"] 74 | 75 | def _build_login_controller(self): 76 | login_view = views.auth.LoginView('username', 'password') 77 | login_controller = controllers.auth.LoginController(login_view, 78 | self.executor, 79 | self.state_machine) 80 | return login_controller 81 | 82 | def _build_projects_controller(self): 83 | projects_view = views.projects.ProjectsView() 84 | projects_controller = controllers.projects.ProjectsController(projects_view, 85 | self.executor, 86 | self.state_machine) 87 | return projects_controller 88 | 89 | def _build_project_controller(self, project): 90 | project_view = views.projects.ProjectDetailView(project) 91 | project_controller = controllers.projects.ProjectDetailController(project_view, 92 | self.executor, 93 | self.state_machine) 94 | return project_controller 95 | 96 | 97 | class StateMeta(type): 98 | def __new__(cls, clsname, bases, dct): 99 | state_attrs = [k for k in dct if k.isupper()] 100 | state_set = {dct[s] for s in state_attrs} 101 | assert len(state_attrs) == len(state_set), "State attributes must be unique" 102 | dct["STATES"] = state_set 103 | return super().__new__(cls, clsname, bases, dct) 104 | 105 | 106 | class StateMachine(metaclass=StateMeta): 107 | LOGIN = 0 108 | PROJECTS = 1 109 | PROJECT_BACKLOG = 2 110 | PROJECT_MILESTONES = 3 111 | PROJECT_ISSUES = 4 112 | PROJECT_WIKI = 5 113 | PROJECT_ADMIN = 6 114 | 115 | def __init__(self, core, state): 116 | self._core = core 117 | self.state = state 118 | 119 | def logged_in(self, auth_data): 120 | self._core.set_auth_config(auth_data) 121 | self._core.projects_view() 122 | 123 | def projects(self): 124 | self._core.projects_view() 125 | 126 | def project_detail(self, project): 127 | self._core.project_view(project) 128 | 129 | def transition(self, state): 130 | assert state in self.STATES, "{0} is not a valid state".format(state) 131 | self.state = state 132 | self.refresh() 133 | 134 | def refresh(self): 135 | self._core.transition() 136 | -------------------------------------------------------------------------------- /taiga_ncurses/data.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | taiga_ncurses.data 5 | ~~~~~~~~~~~~~~~~~~ 6 | """ 7 | 8 | from datetime import datetime 9 | from collections import OrderedDict 10 | from operator import itemgetter 11 | 12 | # Project 13 | 14 | def total_points(project_stats): 15 | return project_stats.get("total_points", 0) 16 | 17 | def assigned_points(project_stats): 18 | return project_stats.get("assigned_points", 0) 19 | 20 | def defined_points(project_stats): 21 | return project_stats.get("defined_points", 0) 22 | 23 | def defined_points_percentage(project_stats): 24 | return (defined_points(project_stats) * 100 / total_points(project_stats)) if total_points(project_stats) else 0 25 | 26 | def closed_points(project_stats): 27 | return project_stats.get("closed_points", 0) 28 | 29 | def doomline_limit_points(project_stats): 30 | return total_points(project_stats) - assigned_points(project_stats) 31 | 32 | def points(project): 33 | dc = {str(p["id"]): p for p in project.get("points", [])} 34 | return OrderedDict(sorted(dc.items(), key=lambda t: t[1]["order"] )) 35 | 36 | def total_milestones(project_stats): 37 | return project_stats.get("total_milestones", 0) 38 | 39 | def completed_milestones(project): 40 | milestones = project.get("list_of_milestones", []) 41 | now = datetime.now() 42 | return [m for m in milestones if date(m["finish_date"]) < now] 43 | 44 | def current_milestone(project): 45 | milestones = project.get("list_of_milestones", []) 46 | return milestones[-1] if milestones else None 47 | 48 | def current_milestone_name(project): 49 | milestones = project.get("list_of_milestones", []) 50 | return milestones[-1].get("name", "unknown") if milestones else "-----" 51 | 52 | def computable_roles(project): 53 | dc = {str(r["id"]): r for r in project.get("roles", []) if r["computable"]} if "roles" in project else {} 54 | return OrderedDict(sorted(dc.items(), key=lambda t: t[1]["order"] )) 55 | 56 | def list_of_milestones(project, reverse=True): 57 | return sorted(project.get("list_of_milestones", []), key=lambda m: m["finish_date"], reverse=reverse) 58 | 59 | def milestones_are_equals(milestone1, milestone2): 60 | return milestone1.get("id", milestone1) == milestone2.get("id", milestone2) 61 | 62 | def active_memberships(project): 63 | dc = {str(r["user"]): r for r in project.get("memberships", [])} if "memberships" in project else {} 64 | return OrderedDict(sorted(dc.items(), key=lambda t: t[1].get("full_name", "") )) 65 | 66 | 67 | # User Stories 68 | 69 | def us_ref(us): 70 | return us.get("ref", "--") 71 | 72 | def us_subject(us): 73 | return us.get("subject", "------") 74 | 75 | def us_is_blocked(us): 76 | return us.get("is_blocked", False) 77 | 78 | def us_client_requirement(us): 79 | return us.get("client_requirement", False) 80 | 81 | def us_team_requirement(us): 82 | return us.get("team_requirement", False) 83 | 84 | def us_total_points(us): 85 | return us.get("total_points", "--") 86 | 87 | def us_statuses(project): 88 | dc = {str(p["id"]): p for p in project.get("us_statuses", [])} 89 | return OrderedDict(sorted(dc.items(), key=lambda t: t[1]["order"])) 90 | 91 | def issue_types(project): 92 | dc = {str(p["id"]): p for p in project.get("issue_types", [])} 93 | return OrderedDict(sorted(dc.items(), key=lambda t: t[1]["order"])) 94 | 95 | def issue_statuses(project): 96 | dc = {str(p["id"]): p for p in project.get("issue_statuses", [])} 97 | return OrderedDict(sorted(dc.items(), key=lambda t: t[1]["order"])) 98 | 99 | def priorities(project): 100 | dc = {str(p["id"]): p for p in project.get("priorities", [])} 101 | return OrderedDict(sorted(dc.items(), key=lambda t: t[1]["order"])) 102 | 103 | def severities(project): 104 | dc = {str(p["id"]): p for p in project.get("severities", [])} 105 | return OrderedDict(sorted(dc.items(), key=lambda t: t[1]["order"])) 106 | 107 | 108 | # Issues 109 | 110 | def total_issues(issues_stats): 111 | return issues_stats.get("total_issues", 0) 112 | 113 | def opened_issues(issues_stats): 114 | return issues_stats.get("opened_issues", 0) 115 | 116 | def closed_issues(issues_stats): 117 | return issues_stats.get("closed_issues", 0) 118 | 119 | def issues_statuses_stats(issues_stats): 120 | return issues_stats.get("issues_per_status", {}) 121 | 122 | def issues_priorities_stats(issues_stats): 123 | return issues_stats.get("issues_per_priority", {}) 124 | 125 | def issues_severities_stats(issues_stats): 126 | return issues_stats.get("issues_per_severity", {}) 127 | 128 | def issue_ref(issue): 129 | return issue.get("ref", "--") 130 | 131 | def issue_subject(issue): 132 | return issue.get("subject", "------") 133 | 134 | def issue_type_with_color(issue, project, default_color="#ffffff"): 135 | # FIXME: Improvement, get issues_statuses from a project constant 136 | # TODO: Check that the color is in hex format 137 | type_id = issue.get("type", None) 138 | if type_id: 139 | issue_types = {str(p["id"]): p for p in project["issue_types"]} 140 | try: 141 | return (issue_types[str(type_id)]["color"] or default_color, 142 | issue_types[str(type_id)]["name"]) 143 | except KeyError: 144 | pass 145 | return (default_color, "---") 146 | 147 | def issue_status_with_color(issue, project, default_color="#ffffff"): 148 | # FIXME: Improvement, get issues_statuses from a project constant 149 | # TODO: Check that the color is in hex format 150 | status_id = issue.get("status", None) 151 | if status_id: 152 | issue_statuses = {str(p["id"]): p for p in project["issue_statuses"]} 153 | try: 154 | return (issue_statuses[str(status_id)]["color"] or default_color, 155 | issue_statuses[str(status_id)]["name"]) 156 | except KeyError: 157 | pass 158 | return (default_color, "---") 159 | 160 | def issue_priority_with_color(issue, project, default_color="#ffffff"): 161 | # FIXME: Improvement, get priorities from a project constant 162 | # TODO: Check that the color is in hex format 163 | priority_id = issue.get("priority", None) 164 | if priority_id: 165 | priorities = {str(p["id"]): p for p in project["priorities"]} 166 | try: 167 | return (priorities[str(priority_id)]["color"] or default_color, 168 | priorities[str(priority_id)]["name"]) 169 | except KeyError: 170 | pass 171 | return (default_color, "---") 172 | 173 | def issue_severity_with_color(issue, project, default_color="#ffffff"): 174 | # FIXME: Improvement, get severities from a project constant 175 | # TODO: Check that the color is in hex format 176 | severity_id = issue.get("severity", None) 177 | if severity_id: 178 | severities = {str(p["id"]): p for p in project["severities"]} 179 | try: 180 | return (severities[str(severity_id)]["color"] or default_color, 181 | severities[str(severity_id)]["name"]) 182 | except KeyError: 183 | pass 184 | return (default_color, "---") 185 | 186 | def issue_assigned_to_with_color(issue, project, default_color="#ffffff"): 187 | # FIXME: Improvement, get memberships and users from a project constant 188 | # TODO: Check that the color is in hex format 189 | user_id = issue.get("assigned_to", None) 190 | if user_id: 191 | memberships = {str(p["user"]): p for p in project["memberships"]} 192 | try: 193 | return (memberships[str(user_id)]["color"] or default_color, 194 | memberships[str(user_id)]["full_name"]) 195 | except KeyError: 196 | pass 197 | return (default_color, "Unassigned") 198 | 199 | def issue_owner_with_color(issue, project, default_color="#ffffff"): 200 | # FIXME: Improvement, get memberships and users from a project constant 201 | # TODO: Check that the color is in hex format 202 | user_id = issue.get("owner", None) 203 | if user_id: 204 | memberships = {str(p["user"]): p for p in project["memberships"]} 205 | try: 206 | return (memberships[str(user_id)]["color"] or default_color, 207 | memberships[str(user_id)]["full_name"]) 208 | except KeyError: 209 | pass 210 | return (default_color, "Unknown") 211 | 212 | 213 | # Milestone 214 | 215 | def milestone_name(milestone): 216 | return milestone.get("name", "------") 217 | 218 | def milestone_total_points(milestone_stats): 219 | return sum(milestone_stats.get("total_points", {}).values()) 220 | 221 | def milestone_completed_points(milestone_stats): 222 | return sum(milestone_stats["completed_points"]) 223 | 224 | def milestone_closed_points(milestone): 225 | return sum(milestone["closed_points"].values()) 226 | 227 | def milestone_total_tasks(milestone_stats): 228 | return milestone_stats["total_tasks"] 229 | 230 | def milestone_completed_tasks(milestone_stats): 231 | return milestone_stats["completed_tasks"] 232 | 233 | def milestone_estimated_start(milestone_stats): 234 | return milestone_stats["estimated_start"] 235 | 236 | def milestone_finish_date(milestone): 237 | return milestone["finish_date"] 238 | 239 | def milestone_estimated_finish(milestone_stats): 240 | return milestone_stats["estimated_finish"] 241 | 242 | def milestone_remaining_days(milestone_stats): 243 | return (date(milestone_stats["estimated_finish"]) - datetime.now()).days + 1 244 | 245 | # Tasks 246 | 247 | def task_ref(task): 248 | return task.get("ref", "--") 249 | 250 | def task_subject(task): 251 | return task.get("subject", "------") 252 | 253 | def task_finished_date(task): 254 | return task.get("finished_date", None) 255 | 256 | def task_statuses(project): 257 | dc = {str(p["id"]): p for p in project.get("task_statuses", [])} 258 | return OrderedDict(sorted(dc.items(), key=lambda t: t[1]["order"])) 259 | 260 | 261 | def tasks_per_user_story(tasks, user_story): 262 | return [t for t in tasks if t["user_story"] == user_story["id"]] 263 | 264 | def unassigned_tasks(tasks): 265 | return [t for t in tasks if t["user_story"] == None] 266 | 267 | 268 | # Wiki page 269 | 270 | def slug(wiki_page): 271 | return wiki_page.get("slug", "") 272 | 273 | def content(wiki_page): 274 | return wiki_page.get("content", "") 275 | 276 | 277 | # User 278 | 279 | def user_full_name(user): 280 | return user.get("full_name", None) or user.get("email", None) or "John Dou" 281 | 282 | # Misc 283 | 284 | def date(text, date_format="%Y-%m-%d"): 285 | return datetime.strptime(text, date_format) 286 | 287 | def color(user, default_color="#ffffff"): 288 | return user.get("color", default_color) 289 | -------------------------------------------------------------------------------- /taiga_ncurses/executor.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | taiga_ncurses.executor 5 | ~~~~~~~~~~~~~~~~~~~~~~ 6 | """ 7 | 8 | from concurrent.futures import ThreadPoolExecutor 9 | 10 | 11 | class Executor: 12 | def __init__(self, client): 13 | self.client = client 14 | self.pool = ThreadPoolExecutor(2) 15 | 16 | # Auth 17 | def login(self, username, password): 18 | return self.pool.submit(self.client.login, username, password) 19 | 20 | # Project 21 | def projects(self): 22 | return self.pool.submit(self.client.get_projects) 23 | 24 | def project_detail(self, project): 25 | return self.pool.submit(self.client.get_project, id=project["id"]) 26 | 27 | def project_stats(self, project): 28 | return self.pool.submit(self.client.get_project_stats, id=project["id"]) 29 | 30 | def project_issues_stats(self, project): 31 | return self.pool.submit(self.client.get_project_issues_stats, id=project["id"]) 32 | 33 | # Milestones 34 | def milestone(self, milestone, project): 35 | params = {"project": project["id"]} 36 | 37 | return self.pool.submit(self.client.get_milestone, id=milestone["id"], params=params) 38 | 39 | def milestone_stats(self, milestone, project): 40 | params = {"project": project["id"]} 41 | 42 | return self.pool.submit(self.client.get_milestone_stats, id=milestone["id"], params=params) 43 | 44 | # User Stories 45 | def create_user_story(self, data): 46 | return self.pool.submit(self.client.create_user_story, data_dict=data) 47 | 48 | def create_user_stories_in_bulk(self, data): 49 | return self.pool.submit(self.client.create_user_stories_in_bulk, data_dict=data) 50 | 51 | def update_user_story(self, user_story, data): 52 | return self.pool.submit(self.client.update_user_story, id=user_story["id"], data_dict=data) 53 | 54 | def delete_user_story(self, user_story): 55 | return self.pool.submit(self.client.delete_user_story, id=user_story["id"]) 56 | 57 | def update_user_stories_order(self, user_stories, project): 58 | data = { 59 | "projectId": project["id"], 60 | "bulkStories": [[v["id"], i] for i, v in enumerate(user_stories)] 61 | } 62 | return self.pool.submit(self.client.update_user_stories_order, data_dict=data) 63 | 64 | def unassigned_user_stories(self, project): 65 | params = { 66 | "project": project["id"], 67 | "milestone": None 68 | } 69 | 70 | return self.pool.submit(self.client.get_user_stories, params=params) 71 | 72 | def user_stories(self, milestone, project): 73 | params = { 74 | "project": project["id"], 75 | "milestone": milestone["id"] 76 | } 77 | 78 | return self.pool.submit(self.client.get_user_stories, params=params) 79 | 80 | # Task 81 | def tasks(self, milestone, project): 82 | params = { 83 | "project": project["id"], 84 | "milestone": milestone["id"] 85 | } 86 | 87 | return self.pool.submit(self.client.get_tasks, params=params) 88 | 89 | def create_task(self, data): 90 | return self.pool.submit(self.client.create_task, data_dict=data) 91 | 92 | def update_task(self, task, data): 93 | return self.pool.submit(self.client.update_task, id=task["id"], data_dict=data) 94 | 95 | def delete_task(self, task): 96 | return self.pool.submit(self.client.delete_task, id=task["id"]) 97 | 98 | # Issues 99 | def issues(self, project, order_by=[], filters={}): 100 | params = {"project": project["id"]} 101 | 102 | if order_by: 103 | params["order_by"] = ", ".join(order_by) 104 | 105 | if filters: 106 | params.update(filters) 107 | 108 | return self.pool.submit(self.client.get_issues, params=params) 109 | 110 | def create_issue(self, data): 111 | return self.pool.submit(self.client.create_issue, data_dict=data) 112 | 113 | def update_issue(self, issue, data): 114 | return self.pool.submit(self.client.update_issue, id=issue["id"], data_dict=data) 115 | 116 | def delete_issue(self, issue): 117 | return self.pool.submit(self.client.delete_issue, id=issue["id"]) 118 | 119 | # Wiki 120 | def wiki_pages(self, project): 121 | params={"project": project["id"]} 122 | 123 | return self.pool.submit(self.client.get_wiki_pages, params=params) 124 | -------------------------------------------------------------------------------- /taiga_ncurses/ui/signals.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | taiga_ncurses.ui.signals 5 | ~~~~~~~~~~~~~~~~~~~~~~~~ 6 | """ 7 | 8 | import urwid 9 | 10 | connect = urwid.connect_signal 11 | disconnect = urwid.disconnect_signal 12 | 13 | def emit(widget, signal): 14 | widget._emit(signal) 15 | -------------------------------------------------------------------------------- /taiga_ncurses/ui/views/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | taiga_ncurses.ui.views 5 | ~~~~~~~~~~~~~~~~~~~~~~ 6 | """ 7 | 8 | from . import ( 9 | auth, projects, backlog, milestones, issues, wiki 10 | ) 11 | -------------------------------------------------------------------------------- /taiga_ncurses/ui/views/auth.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | taiga_ncurses.ui.views.auth 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | """ 7 | 8 | from taiga_ncurses.ui.widgets import generic, auth 9 | 10 | from . import base 11 | 12 | 13 | class LoginView(base.View): 14 | login_button = None 15 | 16 | def __init__(self, username_text, password_text): 17 | # Header 18 | header = generic.banner() 19 | # Username and password prompts 20 | max_prompt_length = max(len(username_text), len(password_text)) 21 | max_prompt_padding = max_prompt_length + 2 22 | 23 | self._username_editor = generic.editor() 24 | username_prompt = auth.username_prompt(username_text, self._username_editor, max_prompt_padding) 25 | self._password_editor = generic.editor(mask="♥") 26 | password_prompt = auth.password_prompt(password_text, self._password_editor, max_prompt_padding) 27 | # Login button 28 | self.login_button = generic.button("login") 29 | login_button_widget = auth.wrap_login_button(self.login_button) 30 | # Notifier 31 | self.notifier = generic.Notifier("") 32 | 33 | login_widget = auth.Login([header, 34 | generic.box_solid_fill(" ", 2), 35 | username_prompt, 36 | generic.box_solid_fill(" ", 1), 37 | password_prompt, 38 | generic.box_solid_fill(" ", 2), 39 | login_button_widget, 40 | generic.box_solid_fill(" ", 1), 41 | self.notifier]) 42 | self.widget = generic.center(login_widget) 43 | 44 | @property 45 | def username(self): 46 | return self._username_editor.get_edit_text() 47 | 48 | @property 49 | def password(self): 50 | return self._password_editor.get_edit_text() 51 | -------------------------------------------------------------------------------- /taiga_ncurses/ui/views/backlog.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | taiga_ncurses.ui.views.backlog 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | """ 7 | 8 | import urwid 9 | 10 | from taiga_ncurses.ui.widgets import generic, backlog 11 | 12 | from . import base 13 | 14 | 15 | class ProjectBacklogSubView(base.SubView): 16 | help_popup_title = "Backlog Help Info" 17 | help_popup_info = base.SubView.help_popup_info + ( 18 | ( "Backlog Movements:", ( 19 | ("↑ | k | ctrl p", "Move Up"), 20 | ("↓ | j | ctrl n", "Move Down"), 21 | ("← | h | ctrl b", "Move Left"), 22 | ("→ | l | ctrl f", "Move Right"), 23 | )), 24 | ( "User Stories Actions:", ( 25 | ("n", "Create new US"), 26 | ("N", "Create new USs in bulk"), 27 | ("e", "Edit selected US"), 28 | ("Supr", "Delete selected US"), 29 | ("K", "Move selected US up"), 30 | ("J", "Move selected US down"), 31 | ("w", "Save the position of all USs"), 32 | ("m", "Move selected US to a Milestone"), 33 | ("r", "Refresh the screen") 34 | )), 35 | ) 36 | 37 | def __init__(self, parent_view, project, notifier, tabs): 38 | super().__init__(parent_view) 39 | 40 | self.project = project 41 | self.notifier = notifier 42 | 43 | self.stats = backlog.BacklogStats(project) 44 | self.user_stories = backlog.UserStoryList(project) 45 | 46 | list_walker = urwid.SimpleFocusListWalker([ 47 | tabs, 48 | generic.box_solid_fill(" ", 1), 49 | self.stats, 50 | generic.box_solid_fill(" ", 1), 51 | self.user_stories 52 | ]) 53 | list_walker.set_focus(4) 54 | self.widget = urwid.ListBox(list_walker) 55 | 56 | def open_user_story_form(self, user_story={}): 57 | self.user_story_form = backlog.UserStoryForm(self.project, user_story=user_story) 58 | # FIXME: Calculate the form size 59 | self.parent.show_widget_on_top(self.user_story_form, 80, 24) 60 | 61 | def close_user_story_form(self): 62 | del self.user_story_form 63 | self.parent.hide_widget_on_top() 64 | 65 | def get_user_story_form_data(self): 66 | data = {} 67 | if hasattr(self, "user_story_form"): 68 | data.update({ 69 | "subject": self.user_story_form.subject, 70 | "milestone": self.user_story_form.milestone, 71 | "points": self.user_story_form.points, 72 | "status": self.user_story_form.status, 73 | "is_blocked": self.user_story_form.is_blocked, 74 | "blocked_note": self.user_story_form.blocked_note, 75 | "tags": self.user_story_form.tags, 76 | "description": self.user_story_form.description, 77 | "team_requirement": self.user_story_form.team_requirement, 78 | "client_requirement": self.user_story_form.client_requirement, 79 | "project": self.project["id"], 80 | }) 81 | return data 82 | 83 | def open_user_stories_in_bulk_form(self): 84 | self.user_stories_in_bulk_form = backlog.UserStoriesInBulkForm(self.project) 85 | # FIXME: Calculate the form size 86 | self.parent.show_widget_on_top(self.user_stories_in_bulk_form, 80, 24) 87 | 88 | def close_user_stories_in_bulk_form(self): 89 | del self.user_stories_in_bulk_form 90 | self.parent.hide_widget_on_top() 91 | 92 | def get_user_stories_in_bulk_form_data(self): 93 | data = {} 94 | if hasattr(self, "user_stories_in_bulk_form"): 95 | data.update({ 96 | "bulkStories": self.user_stories_in_bulk_form.subjects, 97 | "projectId": self.project["id"], 98 | }) 99 | return data 100 | 101 | def open_milestones_selector_popup(self, user_story={}): 102 | self.milestone_selector_popup = backlog.MIlestoneSelectorPopup(self.project, user_story) 103 | # FIXME: Calculate the popup size 104 | self.parent.show_widget_on_top(self.milestone_selector_popup, 100, 30) 105 | 106 | def close_milestone_selector_popup(self): 107 | del self.milestone_selector_popup 108 | self.parent.hide_widget_on_top() 109 | -------------------------------------------------------------------------------- /taiga_ncurses/ui/views/base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | taiga_ncurses.ui.views.base 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | """ 7 | 8 | import urwid 9 | 10 | from taiga_ncurses.ui.widgets import generic 11 | 12 | 13 | class View: 14 | widget = None 15 | notifier = None 16 | 17 | def show_widget_on_top(self, top_widget, width, height, align='center', valign='middle', 18 | min_height=10, min_width=10): 19 | self.widget.set_body(self._build_overlay_widget(top_widget, align, width, valign, height, 20 | min_width, min_height)) 21 | 22 | def hide_widget_on_top(self): 23 | self.widget.set_body(self.widget.get_body().bottom_w) 24 | 25 | def _build_overlay_widget(self, top_widget, align, width, valign, height, min_width, min_height): 26 | return urwid.Overlay(top_w=urwid.Filler(top_widget), bottom_w=self.widget.get_body(), 27 | align=align, width=width, valign=valign, height=height, 28 | min_width=min_width, min_height=min_height) 29 | 30 | 31 | class SubView: 32 | parent = None 33 | widget = None 34 | help_popup_title = "Help Info" 35 | help_popup_info = ( 36 | ( "General", ( 37 | ("B", "Go to Backlog Panel"), 38 | ("M", "Go to Milestones Panel"), 39 | ("I", "Go to Issues Panel"), 40 | ("W", "Go to Wiki Panel"), 41 | ("A", "Go to Admin Panel"), 42 | ("P", "Go back to the Projects Panel"), 43 | )), 44 | ) 45 | def __init__(self, parent_view=None): 46 | self.parent = parent_view 47 | 48 | def open_help_popup(self): 49 | self.help_popup = generic.HelpPopup(self.help_popup_title, self.help_popup_info) 50 | row = 5 + sum([3 + len(s[1]) for s in self.help_popup_info]) 51 | col = 60 52 | 53 | self.parent.show_widget_on_top(self.help_popup, col, row) 54 | 55 | def close_help_popup(self): 56 | del self.help_popup 57 | self.parent.hide_widget_on_top() 58 | -------------------------------------------------------------------------------- /taiga_ncurses/ui/views/issues.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | taiga_ncurses.ui.views.issues 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | """ 7 | 8 | import urwid 9 | 10 | from taiga_ncurses.ui.widgets import generic, issues 11 | 12 | from . import base 13 | 14 | 15 | class ProjectIssuesSubView(base.SubView): 16 | help_popup_title = "Issues Help Info" 17 | help_popup_info = base.SubView.help_popup_info + ( 18 | ( "Issues Movements:", ( 19 | ("↑ | k | ctrl p", "Move Up"), 20 | ("↓ | j | ctrl n", "Move Down"), 21 | ("← | h | ctrl b", "Move Left"), 22 | ("→ | l | ctrl f", "Move Right"), 23 | )), 24 | ( "Issue Actions:", ( 25 | ("n", "Create new Issue"), 26 | ("e", "Edit selected Issue"), 27 | ("Supr", "Delete selected Issue"), 28 | ("f", "Filter issues"), 29 | ("r", "Refresh the screen") 30 | )), 31 | ) 32 | 33 | def __init__(self, parent_view, project, notifier, tabs): 34 | super().__init__(parent_view) 35 | 36 | self.project = project 37 | self.notifier = notifier 38 | self.filters = {} 39 | 40 | self.stats = issues.IssuesStats(project) 41 | self.filters_info = issues.IssuesFiltersInfo(project, self.filters) 42 | self.issues_header = issues.IssuesListHeader() 43 | self.issues = issues.IssuesList(project) 44 | 45 | list_walker = urwid.SimpleFocusListWalker([ 46 | tabs, 47 | generic.box_solid_fill(" ", 1), 48 | self.stats, 49 | generic.box_solid_fill(" ", 1), 50 | self.filters_info, 51 | self.issues_header, 52 | # TODO: FIXME: Calculate the row size wehn populate the issues list. 53 | urwid.BoxAdapter(self.issues, 35), 54 | ]) 55 | list_walker.set_focus(6) 56 | self.widget = urwid.ListBox(list_walker) 57 | 58 | def set_filters(self, filters): 59 | self.filters = filters 60 | self.filters_info.set_filters(self.filters) 61 | 62 | def open_issue_form(self, issue={}): 63 | self.issue_form = issues.IssueForm(self.project, issue=issue) 64 | # FIXME: Calculate the form size 65 | self.parent.show_widget_on_top(self.issue_form, 80, 23) 66 | 67 | def close_issue_form(self): 68 | del self.issue_form 69 | self.parent.hide_widget_on_top() 70 | 71 | def get_issue_form_data(self): 72 | data = {} 73 | if hasattr(self, "issue_form"): 74 | data.update({ 75 | "subject": self.issue_form.subject, 76 | "type": self.issue_form.type, 77 | "status": self.issue_form.status, 78 | "priority": self.issue_form.priority, 79 | "severity": self.issue_form.severity, 80 | "assigned_to": self.issue_form.assigned_to, 81 | "tags": self.issue_form.tags, 82 | "description": self.issue_form.description, 83 | "project": self.project["id"], 84 | }) 85 | return data 86 | 87 | def open_filters_popup(self): 88 | self.filters_popup = issues.FiltersPopup(self.project, self.filters) 89 | # FIXME: Calculate the popup size 90 | self.parent.show_widget_on_top(self.filters_popup, 130, 28) 91 | 92 | def close_filters_popup(self): 93 | del self.filters_popup 94 | self.parent.hide_widget_on_top() 95 | 96 | def get_filters_popup_data(self): 97 | data = {} 98 | if hasattr(self, "filters_popup"): 99 | data.update(self.filters_popup.filters) 100 | return data 101 | -------------------------------------------------------------------------------- /taiga_ncurses/ui/views/milestones.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | taiga_ncurses.ui.views.milestones 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | """ 7 | 8 | import urwid 9 | 10 | from taiga_ncurses.ui.widgets import generic, milestones, backlog 11 | 12 | from . import base 13 | 14 | 15 | class ProjectMilestoneSubView(base.SubView): 16 | help_popup_title = "Milestone Help Info" 17 | help_popup_info = base.SubView.help_popup_info + ( 18 | ( "Milestone Movements:", ( 19 | ("↑ | k | ctrl p", "Move Up"), 20 | ("↓ | j | ctrl n", "Move Down"), 21 | ("← | h | ctrl b", "Move Left"), 22 | ("→ | l | ctrl f", "Move Right"), 23 | )), 24 | ( "Milestone Actions:", ( 25 | ("m", "Change to another Milestone"), 26 | ("N", "Create new US"), 27 | ("n", "Create new Task"), 28 | ("e", "Edit selected US/Task"), 29 | ("Supr", "Delete selected US/Task"), 30 | ("r", "Refresh the screen") 31 | )), 32 | ) 33 | 34 | def __init__(self, parent_view, project, notifier, tabs): 35 | super().__init__(parent_view) 36 | self.notifier = notifier 37 | 38 | self._project = project 39 | self._milestone = {} 40 | self._user_stories = [] 41 | self._tasks = [] 42 | 43 | self.info = milestones.MilestoneInfo(self._project) 44 | self.stats = milestones.MilestoneStats(self._project) 45 | self.taskboard = milestones.MilestoneTaskboard(self._project) 46 | 47 | self.widget = urwid.ListBox(urwid.SimpleListWalker([ 48 | tabs, 49 | generic.box_solid_fill(" ", 1), 50 | self.info, 51 | generic.box_solid_fill(" ", 1), 52 | self.stats, 53 | generic.box_solid_fill(" ", 1), 54 | # TODO: FIXME: Calculate the row size wehn populate the tb. 55 | urwid.BoxAdapter(self.taskboard, 46), 56 | ])) 57 | 58 | def open_user_story_form(self, user_story={}): 59 | self.user_story_form = backlog.UserStoryForm(self._project, user_story=user_story) 60 | # FIXME: Calculate the form size 61 | self.parent.show_widget_on_top(self.user_story_form, 80, 24) 62 | 63 | def close_user_story_form(self): 64 | del self.user_story_form 65 | self.parent.hide_widget_on_top() 66 | 67 | def get_user_story_form_data(self): 68 | data = {} 69 | if hasattr(self, "user_story_form"): 70 | data.update({ 71 | "subject": self.user_story_form.subject, 72 | "milestone": self.user_story_form.milestone, 73 | "points": self.user_story_form.points, 74 | "status": self.user_story_form.status, 75 | "is_blocked": self.user_story_form.is_blocked, 76 | "blocked_note": self.user_story_form.blocked_note, 77 | "tags": self.user_story_form.tags, 78 | "description": self.user_story_form.description, 79 | "team_requirement": self.user_story_form.team_requirement, 80 | "client_requirement": self.user_story_form.client_requirement, 81 | "project": self._project["id"] 82 | }) 83 | return data 84 | 85 | def open_task_form(self, task={}): 86 | self.task_form = milestones.TaskForm(self._project, self._user_stories, task=task) 87 | # FIXME: Calculate the form size 88 | self.parent.show_widget_on_top(self.task_form, 80, 21) 89 | 90 | def close_task_form(self): 91 | del self.task_form 92 | self.parent.hide_widget_on_top() 93 | 94 | def get_task_form_data(self): 95 | data = {} 96 | if hasattr(self, "task_form"): 97 | data.update({ 98 | "subject": self.task_form.subject, 99 | "user_story": self.task_form.user_story, 100 | "status": self.task_form.status, 101 | "assigned_to": self.task_form.assigned_to, 102 | "is_iocaine": self.task_form.is_iocaine, 103 | "tags": self.task_form.tags, 104 | "description": self.task_form.description, 105 | "project": self._project["id"], 106 | "milestone": self._milestone["id"] 107 | }) 108 | return data 109 | 110 | def open_milestones_selector_popup(self, current_milestone={}): 111 | self.milestone_selector_popup = milestones.MIlestoneSelectorPopup(self._project, current_milestone) 112 | # FIXME: Calculate the popup size 113 | self.parent.show_widget_on_top(self.milestone_selector_popup, 100, 28) 114 | 115 | def close_milestone_selector_popup(self): 116 | del self.milestone_selector_popup 117 | self.parent.hide_widget_on_top() 118 | -------------------------------------------------------------------------------- /taiga_ncurses/ui/views/projects.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | taiga_ncurses.ui.views.projects 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | """ 7 | 8 | import functools 9 | 10 | import urwid 11 | 12 | from taiga_ncurses.ui.widgets import generic, projects 13 | 14 | from . import base 15 | 16 | from .backlog import ProjectBacklogSubView 17 | from .milestones import ProjectMilestoneSubView 18 | from .issues import ProjectIssuesSubView 19 | from .wiki import ProjectWikiSubView 20 | 21 | 22 | class ProjectsView(base.View): 23 | project_buttons = None 24 | projects = None 25 | 26 | def __init__(self): 27 | self.projects = [] 28 | self.project_buttons = [] 29 | grid = generic.Grid([], 4, 2, 2, 'center') 30 | fill = urwid.Filler(grid, min_height=40) 31 | self.notifier = generic.FooterNotifier("") 32 | self.widget = urwid.Frame(fill, 33 | header=generic.Header(), 34 | footer=generic.Footer(self.notifier)) 35 | 36 | def populate(self, projects): 37 | self.projects = projects 38 | self.project_buttons = [urwid.Button(p['name']) for p in projects] 39 | min_width = functools.reduce(max, (len(p['name']) for p in projects), 0) 40 | grid = generic.Grid(self.project_buttons, min_width * 4, 2, 2, 'center') 41 | self.widget.set_body(urwid.Filler(grid, min_height=40)) 42 | 43 | 44 | class ProjectDetailView(base.View): 45 | TABS = ["Backlog", "Milestones", "Issues", "Wiki", "Admin"] 46 | 47 | def __init__(self, project): 48 | self.project = project 49 | 50 | self.notifier = generic.FooterNotifier("") 51 | 52 | self.tabs = generic.Tabs(self.TABS) 53 | 54 | # Subviews 55 | self.backlog = ProjectBacklogSubView(self, project, self.notifier, self.tabs) 56 | self.sprint = ProjectMilestoneSubView(self, project, self.notifier, self.tabs) 57 | self.issues = ProjectIssuesSubView(self, project, self.notifier, self.tabs) 58 | self.wiki = ProjectWikiSubView(self, project, self.notifier, self.tabs) 59 | self.admin = ProjectAdminSubView(self, project, self.notifier, self.tabs) 60 | 61 | self.widget = urwid.Frame(self.backlog.widget, 62 | header=projects.ProjectDetailHeader(project), 63 | footer=generic.Footer(self.notifier)) 64 | 65 | def backlog_view(self): 66 | self.tabs.tab_list.focus = 0 67 | self.widget.set_body(self.backlog.widget) 68 | 69 | def sprint_view(self): 70 | self.tabs.tab_list.focus = 1 71 | self.widget.set_body(self.sprint.widget) 72 | 73 | def issues_view(self): 74 | self.tabs.tab_list.focus = 2 75 | self.widget.set_body(self.issues.widget) 76 | 77 | def wiki_view(self): 78 | self.tabs.tab_list.focus = 3 79 | self.widget.set_body(self.wiki.widget) 80 | 81 | def admin_view(self): 82 | self.tabs.tab_list.focus = 4 83 | self.widget.set_body(self.admin.widget) 84 | 85 | 86 | class ProjectAdminSubView(base.SubView): 87 | def __init__(self, parent_view, project, notifier, tabs): 88 | super().__init__(parent_view) 89 | 90 | self.project = project 91 | self.notifier = notifier 92 | 93 | self.widget = urwid.ListBox(urwid.SimpleListWalker([ 94 | tabs, 95 | generic.box_solid_fill(" ", 1), 96 | ])) 97 | -------------------------------------------------------------------------------- /taiga_ncurses/ui/views/wiki.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | taiga_ncurses.ui.views.wiki 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | """ 7 | 8 | import urwid 9 | 10 | from taiga_ncurses.ui.widgets import generic, wiki 11 | 12 | from . import base 13 | 14 | 15 | class ProjectWikiSubView(base.SubView): 16 | def __init__(self, parent_view, project, notifier, tabs): 17 | super().__init__(parent_view) 18 | 19 | self.project = project 20 | self.notifier = notifier 21 | 22 | self.wiki_page = wiki.WikiPage(project) 23 | 24 | list_walker = urwid.SimpleFocusListWalker([ 25 | tabs, 26 | generic.box_solid_fill(" ", 1), 27 | self.wiki_page, 28 | ]) 29 | list_walker.set_focus(2) 30 | self.widget = urwid.ListBox(list_walker) 31 | -------------------------------------------------------------------------------- /taiga_ncurses/ui/widgets/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | taiga_ncurses.ui.widgets 5 | ~~~~~~~~~~~~~~~~~~~~~~~~ 6 | """ 7 | 8 | from . import ( 9 | generic, auth, projects, backlog, milestones, issues, wiki 10 | ) 11 | -------------------------------------------------------------------------------- /taiga_ncurses/ui/widgets/auth.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | taiga_ncurses.ui.widgets.auth 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | """ 7 | 8 | import urwid 9 | 10 | from . import mixins 11 | 12 | 13 | def username_prompt(username_text, editor, max_prompt_padding): 14 | username = urwid.Text(username_text, "center") 15 | return urwid.Columns([(len(username_text), username), 16 | (max_prompt_padding - len(username_text), urwid.Text("")), 17 | urwid.AttrWrap(editor, "editor")]) 18 | 19 | 20 | def password_prompt(password_text, editor, max_prompt_padding): 21 | password = urwid.Text(password_text, "center") 22 | return urwid.Columns([(len(password_text), password), 23 | (max_prompt_padding - len(password_text), urwid.Text("")), 24 | urwid.AttrWrap(editor, "password-editor")]) 25 | 26 | 27 | def wrap_login_button(button): 28 | return urwid.LineBox(button) 29 | 30 | 31 | class Login(mixins.FormMixin, urwid.ListBox): 32 | def __init__(self, widgets): 33 | super(Login, self).__init__(urwid.SimpleListWalker(widgets)) 34 | -------------------------------------------------------------------------------- /taiga_ncurses/ui/widgets/generic.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | taiga_ncurses.ui.widgets.generic 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | """ 7 | 8 | import urwid 9 | 10 | from . import mixins, utils 11 | 12 | 13 | def box_solid_fill(char, height): 14 | sf = urwid.SolidFill(char) 15 | return urwid.BoxAdapter(sf, height=height) 16 | 17 | 18 | def wrap_in_whitespace(widget, cls=urwid.Columns): 19 | whitespace = urwid.SolidFill(" ") 20 | return cls([whitespace, widget, whitespace]) 21 | 22 | 23 | def center(widget): 24 | return wrap_in_whitespace(wrap_in_whitespace(widget), cls=urwid.Pile) 25 | 26 | 27 | def banner(): 28 | bt = urwid.BigText("Taiga", font=urwid.font.HalfBlock7x7Font()) 29 | btwp = urwid.Padding(bt, "center", width="clip") 30 | return urwid.AttrWrap(btwp, "green") 31 | 32 | 33 | def button(text, align=None): 34 | return PlainButton(text.upper(), align) 35 | 36 | 37 | def editor(mask=None): 38 | if mask is None: 39 | return urwid.Edit() 40 | else: 41 | return urwid.Edit(mask=mask) 42 | 43 | 44 | class Header(mixins.NonSelectableMixin, urwid.WidgetWrap): 45 | def __init__(self): 46 | text = urwid.Text("TAIGA") 47 | self.account_button = PlainButton("My account") 48 | cols = urwid.Columns([ 49 | ("weight", 0.9, text), 50 | ("weight", 0.1, urwid.AttrMap(self.account_button, "account-button")), 51 | ]) 52 | super().__init__(urwid.AttrMap(cols, "green-bg")) 53 | 54 | 55 | class Notifier(mixins.NotifierMixin, mixins.NonSelectableMixin, urwid.Text): 56 | pass 57 | 58 | 59 | class PlainButton(mixins.PlainButtonMixin, urwid.Button): 60 | ALIGN = "center" 61 | 62 | def __init__(self, text, align=None): 63 | super().__init__(text) 64 | self._label.set_align_mode(self.ALIGN if align is None else align) 65 | 66 | 67 | class SubmitButton(PlainButton): 68 | def __init__(self, text, align=None): 69 | super().__init__(text, align) 70 | 71 | 72 | class CancelButton(PlainButton): 73 | def __init__(self, text, align=None): 74 | super().__init__(text, align) 75 | 76 | 77 | class FooterNotifier(Notifier): 78 | ALIGN = "left" 79 | ERROR_PREFIX = "[ERROR]: " 80 | ERROR_ATTR = "footer-error" 81 | INFO_PREFIX = "[INFO]: " 82 | INFO_ATTR = "footer-info" 83 | 84 | 85 | class Footer(mixins.NonSelectableMixin, urwid.WidgetWrap): 86 | def __init__(self, notifier): 87 | assert isinstance(notifier, FooterNotifier) 88 | cols = urwid.Columns([ 89 | ("weight", 0.9, urwid.AttrMap(notifier, "footer")), 90 | ("weight", 0.1, urwid.AttrMap(PlainButton("? Help"), "help-button")), 91 | ]) 92 | super().__init__(cols) 93 | 94 | 95 | 96 | 97 | class Grid(mixins.ViMotionMixin, mixins.EmacsMotionMixin, urwid.GridFlow): 98 | pass 99 | 100 | 101 | class Tabs(mixins.NonSelectableMixin, urwid.WidgetWrap): 102 | def __init__(self, tabs, focus=0): 103 | self.tab_list = urwid.MonitoredFocusList(tabs) 104 | self.tab_list.focus = focus 105 | self.tab_list.set_focus_changed_callback(self.rebuild_tabs) 106 | 107 | cols = [urwid.AttrMap(self.tab(t), "active-tab" if i == self.tab_list.focus else "inactive-tab") 108 | for i, t in enumerate(tabs)] 109 | self.columns = urwid.Columns(cols) 110 | 111 | super().__init__(self.columns) 112 | 113 | def rebuild_tabs(self, new_focus): 114 | for i, c in enumerate(self.columns.contents): 115 | widget, _ = c 116 | widget.set_attr_map({None: "active-tab" if i == new_focus else "inactive-tab"}) 117 | 118 | def tab(self, text): 119 | return urwid.LineBox(urwid.Text(text + " ")) 120 | 121 | 122 | class HelpPopup(urwid.WidgetWrap): 123 | # FIXME: Remove solid_fill and use the Fill decorator 124 | def __init__(self, title="Help", content={}): 125 | contents = [box_solid_fill(" ", 1)] 126 | 127 | for name, actions in content: 128 | contents += self._section(name, actions) 129 | contents.append(box_solid_fill(" ", 1)) 130 | 131 | contents.append(self._buttons()) 132 | contents.append(box_solid_fill(" ", 1)) 133 | 134 | self.widget = urwid.Pile(contents) 135 | super().__init__(urwid.AttrMap(urwid.LineBox(urwid.Padding(self.widget, right=2, left=2), 136 | title), "popup")) 137 | def _section(self, name, actions): 138 | items = [urwid.Text(("popup-section-title", name))] 139 | items.append(box_solid_fill(" ", 1)) 140 | 141 | for keys, description in actions: 142 | colum_items = [(18, urwid.Padding(ListText(keys, align="center"), right=2))] 143 | colum_items.append(urwid.Text(description)) 144 | items.append(urwid.Padding(urwid.Columns(colum_items), left=2)) 145 | 146 | return items 147 | 148 | def _buttons(self): 149 | self.close_button = PlainButton("Close") 150 | 151 | colum_items = [("weight", 1, urwid.Text(""))] 152 | colum_items.append((15, urwid.AttrMap(urwid.Padding(self.close_button, right=1, left=2), 153 | "popup-cancel-button"))) 154 | return urwid.Columns(colum_items) 155 | 156 | 157 | class ListCell(urwid.WidgetWrap): 158 | def __init__(self, text): 159 | text_widget = urwid.AttrMap(ListText(text), "default") 160 | widget = urwid.AttrMap(urwid.LineBox(text_widget), "green") 161 | super().__init__(widget) 162 | 163 | 164 | class ButtonCell(urwid.WidgetWrap): 165 | def __init__(self, button): 166 | text_widget = urwid.AttrMap(button, "default", "focus-header") 167 | widget = urwid.AttrMap(urwid.LineBox(text_widget), "green") 168 | super().__init__(widget) 169 | 170 | 171 | class ListText(mixins.IgnoreKeyPressMixin, urwid.Text): 172 | def __init__(self, text, align="center"): 173 | super().__init__(text, align=align) 174 | 175 | 176 | class RowDivider(urwid.WidgetWrap): 177 | def __init__(self, attr_map="default", div_char="-"): 178 | widget = urwid.AttrMap(urwid.Divider(div_char), attr_map) 179 | super().__init__(widget) 180 | 181 | 182 | class SemaphorePercentText(ListText): 183 | """ 184 | Get a number and a max_value and print it with a concrete color: 185 | * red: value <= 20% 186 | * yellos: 20% < vale < max_value 187 | * green: vale == max_vale 188 | 189 | If invert value is True red will be green and viceversa 190 | """ 191 | def __init__(self, value, max_value=100.0, invert=False): 192 | color = "yellow" 193 | if value <= max_value * 0.2: 194 | color = "red" if not invert else "green" 195 | elif value == max_value: 196 | color = "green" if not invert else "red" 197 | text = [(color, str(value))] 198 | 199 | super().__init__(text) 200 | 201 | 202 | class ComboBox(urwid.PopUpLauncher): 203 | """ 204 | A button launcher for the combobox menu 205 | """ 206 | signals = ["change"] 207 | 208 | def __init__(self, items, selected_value=None, style="default", enable_markup=False, 209 | on_state_change=None, user_data=None): 210 | self.menu = ComboBoxMenu(items, style) 211 | 212 | self.enable_markup = enable_markup 213 | 214 | self.on_state_change = on_state_change 215 | self.user_data = user_data 216 | 217 | selected_item = utils.find(lambda x: x.value == selected_value, self.menu.items) or self.menu.items[0] 218 | selected_item.set_state(True) 219 | if self.enable_markup: 220 | self._button = ComboBoxButton(selected_item.get_label_markup(), align="left") 221 | else: 222 | self._button = ComboBoxButton(selected_item.get_label(), align="left") 223 | 224 | 225 | super().__init__(self._button) 226 | 227 | urwid.connect_signal(self.original_widget, "click", lambda b: self.open_pop_up()) 228 | for i in self.menu.items: 229 | urwid.connect_signal(i, "click", self.item_changed) 230 | urwid.connect_signal(i, "quit", self.quit_menu) 231 | 232 | def create_pop_up(self): 233 | """ 234 | Create the pop up widget 235 | """ 236 | return self.menu 237 | 238 | def get_pop_up_parameters(self): 239 | """ 240 | Configuration dictionary for the pop up 241 | """ 242 | 243 | return {"left": 2, 244 | "top": 1, 245 | "overlay_width": max([len(i.get_label()) for i in self.menu.items]) + 7, 246 | "overlay_height": len(self.menu.items) + 2} 247 | 248 | def item_changed(self, item, state): 249 | if state: 250 | if self.enable_markup: 251 | selection = item.get_label_markup() 252 | else: 253 | selection = item.get_label() 254 | self._button.set_label(selection) 255 | 256 | if self.on_state_change: 257 | self.on_state_change(self, item, state, user_data=self.user_data) 258 | 259 | self.close_pop_up() 260 | self._emit("change", item, state) 261 | 262 | def quit_menu(self, widget): 263 | self.close_pop_up() 264 | 265 | def get_selected(self): 266 | return self.menu.get_selected() 267 | 268 | 269 | class ComboBoxButton(PlainButton): 270 | combobox_mark = "▼" 271 | 272 | def set_label(self, label): 273 | s = " {}".format(self.combobox_mark) 274 | super().set_label([label, s]) 275 | 276 | 277 | class ComboBoxMenu(urwid.WidgetWrap): 278 | """ 279 | A menu shown when parent is activated. 280 | """ 281 | signals = ["close"] 282 | 283 | def __init__(self, items, style): 284 | """ 285 | Initialize items list 286 | 287 | Item must be a list of dicts with "label" and "value" items. 288 | """ 289 | self.group = [] 290 | self.items = [] 291 | 292 | for i in items: 293 | self.append(i) 294 | 295 | self.walker = urwid.Pile(self.items) 296 | super().__init__(urwid.AttrWrap(urwid.Filler(urwid.LineBox(self.walker)), "selectable", style)) 297 | 298 | def append(self, item): 299 | """ 300 | Append an item to the menu 301 | """ 302 | r = MenuItem(self.group, item) 303 | self.items.append(r) 304 | 305 | def get_item(self, index): 306 | """ 307 | Get an item by index 308 | """ 309 | return self.items[index] 310 | 311 | def get_selected(self): 312 | """ 313 | Return the index of the selected item 314 | """ 315 | for index, item in enumerate(self.items): 316 | if item.state is True: 317 | return item 318 | return None 319 | 320 | 321 | class MenuItem(urwid.RadioButton): 322 | """ 323 | A RadioButton with a 'click' signal 324 | """ 325 | signals = urwid.RadioButton.signals + ["click", "quit"] 326 | 327 | def __init__(self, group, label_value, state='first True', on_state_change=None, user_data=None): 328 | markup, value = label_value 329 | 330 | self._markup = markup 331 | self.value = value 332 | 333 | super().__init__(group, markup, state='first True', on_state_change=None, user_data=None) 334 | 335 | def get_label_markup(self): 336 | return self._markup 337 | 338 | def keypress(self, size, key): 339 | command = urwid.command_map[key] 340 | 341 | if command == "activate": 342 | self._emit("click", True) 343 | elif command == "menu": 344 | self._emit("quit") 345 | 346 | super(MenuItem, self).keypress(size, key) 347 | return key 348 | -------------------------------------------------------------------------------- /taiga_ncurses/ui/widgets/mixins.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | taiga_ncurses.ui.widgets.mixins 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | """ 7 | 8 | import urwid 9 | 10 | 11 | class IgnoreKeyPressMixin: 12 | def keypress(self, size, key): 13 | return key 14 | 15 | 16 | class KeyPressMixin: 17 | signals = ["click"] 18 | 19 | def keypress(self, size, key): 20 | """ 21 | Send 'click' signal on 'activate' command. 22 | 23 | >>> assert Button._command_map[' '] == 'activate' 24 | >>> assert Button._command_map['enter'] == 'activate' 25 | >>> size = (15,) 26 | >>> b = Button("Cancel") 27 | >>> clicked_buttons = [] 28 | >>> def handle_click(button): 29 | ... clicked_buttons.append(button.label) 30 | >>> connect_signal(b, 'click', handle_click) 31 | >>> b.keypress(size, 'enter') 32 | >>> b.keypress(size, ' ') 33 | >>> clicked_buttons # ... = u in Python 2 34 | [...'Cancel', ...'Cancel'] 35 | """ 36 | if self._command_map[key] != urwid.ACTIVATE: 37 | return key 38 | 39 | self._emit('click') 40 | 41 | def mouse_event(self, size, event, button, x, y, focus): 42 | """ 43 | Send 'click' signal on button 1 press. 44 | 45 | >>> size = (15,) 46 | >>> b = Button("Ok") 47 | >>> clicked_buttons = [] 48 | >>> def handle_click(button): 49 | ... clicked_buttons.append(button.label) 50 | >>> connect_signal(b, 'click', handle_click) 51 | >>> b.mouse_event(size, 'mouse press', 1, 4, 0, True) 52 | True 53 | >>> b.mouse_event(size, 'mouse press', 2, 4, 0, True) # ignored 54 | False 55 | >>> clicked_buttons # ... = u in Python 2 56 | [...'Ok'] 57 | """ 58 | if button != 1 or not urwid.util.is_mouse_press(event): 59 | return False 60 | 61 | self._emit('click') 62 | return True 63 | 64 | 65 | class FormMixin: 66 | FORM_KEYS = { 67 | "tab": "down", 68 | "shift tab": "up", 69 | } 70 | 71 | def keypress(self, size, key): 72 | key = self.FORM_KEYS.get(key, key) 73 | return super().keypress(size, key) 74 | 75 | class ViMotionMixin: 76 | VI_KEYS = { 77 | "j": "down", 78 | "k": "up", 79 | "h": "left", 80 | "l": "right", 81 | } 82 | 83 | def keypress(self, size, key): 84 | key = self.VI_KEYS.get(key, key) 85 | return super().keypress(size, key) 86 | 87 | class EmacsMotionMixin: 88 | EMACS_KEYS = { 89 | "ctrl n": "down", 90 | "ctrl p": "up", 91 | "ctrl b": "left", 92 | "ctrl f": "right", 93 | } 94 | 95 | def keypress(self, size, key): 96 | key = self.EMACS_KEYS.get(key, key) 97 | return super().keypress(size, key) 98 | 99 | class NotifierMixin: 100 | ERROR_PREFIX = "" 101 | ERROR_ATTR = "error" 102 | INFO_PREFIX = "" 103 | INFO_ATTR = "info" 104 | ALIGN = "center" 105 | 106 | def error_msg(self, text): 107 | self.set_text((self.ERROR_ATTR, self.ERROR_PREFIX + text)) 108 | self.set_align_mode(self.ALIGN) 109 | 110 | def info_msg(self, text): 111 | self.set_text((self.INFO_ATTR, self.INFO_PREFIX + text)) 112 | self.set_align_mode(self.ALIGN) 113 | 114 | def clear_msg(self): 115 | self.set_text("") 116 | 117 | 118 | class PlainButtonMixin: 119 | button_left = urwid.Text("") 120 | button_right = urwid.Text("") 121 | 122 | 123 | class NonSelectableMixin: 124 | def selectable(self): 125 | return False 126 | -------------------------------------------------------------------------------- /taiga_ncurses/ui/widgets/projects.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | taiga_ncurses.ui.widgets.projects 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | """ 7 | 8 | import urwid 9 | 10 | from . import generic, mixins 11 | 12 | 13 | class ProjectDetailHeader(mixins.NonSelectableMixin, urwid.WidgetWrap): 14 | def __init__(self, project): 15 | text = urwid.Text("TAIGA") 16 | self.title = urwid.Text(project["name"], align="left") 17 | self.projects_button = generic.PlainButton("My projects") 18 | self.account_button = generic.PlainButton("My account") 19 | cols = urwid.Columns([ 20 | ("weight", 0.1, text), 21 | ("weight", 0.7, self.title), 22 | ("weight", 0.1, urwid.AttrMap(self.projects_button, "projects-button")), 23 | ("weight", 0.1, urwid.AttrMap(self.account_button, "account-button")), 24 | ]) 25 | super().__init__(urwid.AttrMap(cols, "green-bg")) 26 | -------------------------------------------------------------------------------- /taiga_ncurses/ui/widgets/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | taiga_ncurses.ui.widgets.utils 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | """ 7 | 8 | from x256 import x256 9 | 10 | 11 | def color_to_hex(color): 12 | """ 13 | Given either an hexadecimal or HTML color name, return a the hex 14 | approximation without the `#`. 15 | """ 16 | if color.startswith("#"): 17 | return x256.from_hex(color.strip("#")) 18 | else: 19 | return x256.from_html_name(color) 20 | 21 | 22 | def find(f, seq): 23 | """Return first item in sequence where f(item) == True.""" 24 | for item in seq: 25 | if f(item): 26 | return item 27 | -------------------------------------------------------------------------------- /taiga_ncurses/ui/widgets/wiki.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | taiga_ncurses.ui.widgets.wiki 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | """ 7 | 8 | import urwid 9 | 10 | from taiga_ncurses import data 11 | 12 | from . import generic 13 | 14 | 15 | class WikiPage(urwid.WidgetWrap): 16 | on_wiki_page_change = None 17 | 18 | def __init__(self, project): 19 | self.project = project 20 | 21 | self.widget = urwid.Pile([generic.ListText("No page found")]) 22 | super().__init__(self.widget) 23 | 24 | def populate(self, wiki_pages, wiki_page): 25 | items = tuple((data.slug(p), p) for p in wiki_pages) 26 | selected = wiki_page 27 | pages_combo = generic.ComboBox(items, selected_value=selected, style="cyan", 28 | on_state_change=self.on_wiki_page_change) 29 | 30 | page_combo_size = max([len(p["slug"]) for p in wiki_pages]) + 8 31 | 32 | content_widget = urwid.Edit(edit_text=data.content(wiki_page), multiline=True, wrap='any', 33 | allow_tab=True) 34 | 35 | self.widget.contents = [ 36 | (generic.RowDivider(div_char=" "), ("weight", 0.1)), 37 | (urwid.Padding(urwid.LineBox(pages_combo), "center", page_combo_size, 10, 0, 0), ('weight', 1)), 38 | (generic.RowDivider(div_char=" "), ("weight", 0.1)), 39 | (content_widget, ('pack', None)), 40 | (generic.RowDivider(div_char=" "), ("weight", 0.1)), 41 | (urwid.Padding(self._buttons(), right=2, left=2), ('weight', 1)), 42 | (generic.RowDivider(div_char=" "), ("weight", 0.1)) 43 | ] 44 | self.widget.contents.focus = 3 45 | 46 | def _buttons(self): 47 | self.save_button = generic.PlainButton("Save") 48 | self.reset_button = generic.PlainButton("Reset") 49 | 50 | colum_items = [("weight", 1, urwid.Text(""))] 51 | colum_items.append((15, urwid.AttrMap(urwid.Padding(self.save_button, right=2, left=2), 52 | "submit-button") )) 53 | colum_items.append((2, urwid.Text(" "))) 54 | colum_items.append((15, urwid.AttrMap(urwid.Padding(self.reset_button, right=1, left=2), 55 | "cancel-button") )) 56 | return urwid.Columns(colum_items) 57 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaleidos-ventures/taiga-ncurses/65312098f2d167762e0dbd1c16019754ab64d068/tests/__init__.py -------------------------------------------------------------------------------- /tests/controllers/test_backlog_controller.py: -------------------------------------------------------------------------------- 1 | from concurrent.futures import Future 2 | from unittest import mock 3 | 4 | from taiga_ncurses.ui import signals, views 5 | from taiga_ncurses import controllers 6 | from taiga_ncurses.config import settings 7 | from taiga_ncurses.executor import Executor 8 | from taiga_ncurses.core import StateMachine 9 | 10 | from tests import factories 11 | 12 | 13 | def test_backlog_controller_show_the_help_popup(): 14 | project = factories.project() 15 | project_view = views.projects.ProjectDetailView(project) 16 | executor = factories.patched_executor() 17 | _ = mock.Mock() 18 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 19 | 20 | assert not hasattr(project_detail_controller.view.backlog, "help_popup") 21 | project_detail_controller.handle(settings.data.backlog.keys.help) 22 | assert hasattr(project_detail_controller.view.backlog, "help_popup") 23 | 24 | def test_backlog_controller_close_the_help_popup(): 25 | project = factories.project() 26 | project_view = views.projects.ProjectDetailView(project) 27 | executor = factories.patched_executor() 28 | _ = mock.Mock() 29 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 30 | project_detail_controller.handle(settings.data.backlog.keys.help) 31 | 32 | assert hasattr(project_detail_controller.view.backlog, "help_popup") 33 | help_popup = project_detail_controller.view.backlog.help_popup 34 | signals.emit(help_popup.close_button, "click") 35 | assert not hasattr(project_detail_controller.view.backlog, "help_popup") 36 | 37 | def test_backlog_controller_reload(): 38 | project = factories.project() 39 | project_view = views.projects.ProjectDetailView(project) 40 | executor = factories.patched_executor() 41 | _ = mock.Mock() 42 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 43 | executor.project_stats.reset_mock() 44 | executor.unassigned_user_stories.reset_mock() 45 | 46 | assert executor.project_stats.call_count == 0 47 | assert executor.unassigned_user_stories.call_count == 0 48 | project_detail_controller.handle(settings.data.backlog.keys.reload) 49 | assert executor.project_stats.call_count == 1 50 | assert executor.unassigned_user_stories.call_count == 1 51 | 52 | def test_backlog_controller_show_the_new_user_story_form(): 53 | project = factories.project() 54 | project_view = views.projects.ProjectDetailView(project) 55 | executor = factories.patched_executor() 56 | _ = mock.Mock() 57 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 58 | 59 | assert not hasattr(project_detail_controller.view.backlog, "user_story_form") 60 | project_detail_controller.handle(settings.data.backlog.keys.create) 61 | assert hasattr(project_detail_controller.view.backlog, "user_story_form") 62 | 63 | def test_backlog_controller_cancel_the_new_user_story_form(): 64 | project = factories.project() 65 | project_view = views.projects.ProjectDetailView(project) 66 | executor = factories.patched_executor() 67 | _ = mock.Mock() 68 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 69 | project_detail_controller.handle(settings.data.backlog.keys.create) 70 | 71 | assert hasattr(project_detail_controller.view.backlog, "user_story_form") 72 | form = project_detail_controller.view.backlog.user_story_form 73 | signals.emit(form.cancel_button, "click") 74 | assert not hasattr(project_detail_controller.view.backlog, "user_story_form") 75 | 76 | def test_backlog_controller_submit_new_user_story_form_with_errors(): 77 | project = factories.project() 78 | project_view = views.projects.ProjectDetailView(project) 79 | project_view.backlog.notifier = mock.Mock() 80 | executor = factories.patched_executor() 81 | _ = mock.Mock() 82 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 83 | project_detail_controller.handle(settings.data.backlog.keys.create) 84 | form = project_detail_controller.view.backlog.user_story_form 85 | 86 | signals.emit(form.save_button, "click") 87 | assert project_view.backlog.notifier.error_msg.call_count == 1 88 | 89 | def test_backlog_controller_submit_new_user_story_form_successfully(): 90 | us_subject = "Create a new user story" 91 | project = factories.project() 92 | project_view = views.projects.ProjectDetailView(project) 93 | project_view.backlog.notifier = mock.Mock() 94 | executor = factories.patched_executor(create_user_story_response=factories.future( 95 | factories.successful_create_user_story_response(us_subject))) 96 | _ = mock.Mock() 97 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 98 | project_detail_controller.handle(settings.data.backlog.keys.create) 99 | form = project_detail_controller.view.backlog.user_story_form 100 | project_view.backlog.notifier.reset_mock() 101 | 102 | form._subject_edit.set_edit_text(us_subject) 103 | signals.emit(form.save_button, "click") 104 | assert project_view.backlog.notifier.info_msg.call_count == 1 105 | assert executor.create_user_story.call_args.call_list()[0][0][0]["subject"] == us_subject 106 | assert executor.create_user_story.call_count == 1 107 | assert executor.create_user_story.return_value.result()["subject"] == us_subject 108 | 109 | def test_backlog_controller_show_the_edit_user_story_form(): 110 | project = factories.project() 111 | project_view = views.projects.ProjectDetailView(project) 112 | executor = factories.patched_executor() 113 | _ = mock.Mock() 114 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 115 | 116 | assert not hasattr(project_detail_controller.view.backlog, "user_story_form") 117 | project_detail_controller.handle(settings.data.backlog.keys.edit) 118 | assert hasattr(project_detail_controller.view.backlog, "user_story_form") 119 | assert (project_detail_controller.view.backlog.user_story_form.user_story == 120 | project_detail_controller.view.backlog.user_stories.widget.get_focus().user_story) 121 | 122 | def test_backlog_controller_cancel_the_edit_user_story_form(): 123 | project = factories.project() 124 | project_view = views.projects.ProjectDetailView(project) 125 | executor = factories.patched_executor() 126 | _ = mock.Mock() 127 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 128 | project_detail_controller.handle(settings.data.backlog.keys.edit) 129 | 130 | assert hasattr(project_detail_controller.view.backlog, "user_story_form") 131 | form = project_detail_controller.view.backlog.user_story_form 132 | signals.emit(form.cancel_button, "click") 133 | assert not hasattr(project_detail_controller.view.backlog, "user_story_form") 134 | 135 | def test_backlog_controller_submit_the_edit_user_story_form_with_errors(): 136 | project = factories.project() 137 | project_view = views.projects.ProjectDetailView(project) 138 | project_view.backlog.notifier = mock.Mock() 139 | executor = factories.patched_executor() 140 | _ = mock.Mock() 141 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 142 | project_detail_controller.handle(settings.data.backlog.keys.edit) 143 | form = project_detail_controller.view.backlog.user_story_form 144 | 145 | form._subject_edit.set_edit_text("") 146 | signals.emit(form.save_button, "click") 147 | assert project_view.backlog.notifier.error_msg.call_count == 1 148 | 149 | def test_backlog_controller_submit_edit_user_story_form_successfully(): 150 | us_subject = "Update a user story" 151 | project = factories.project() 152 | project_view = views.projects.ProjectDetailView(project) 153 | project_view.backlog.notifier = mock.Mock() 154 | executor = factories.patched_executor(update_user_story_response=factories.future( 155 | factories.successful_update_user_story_response(us_subject))) 156 | _ = mock.Mock() 157 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 158 | project_detail_controller.handle(settings.data.backlog.keys.edit) 159 | form = project_detail_controller.view.backlog.user_story_form 160 | project_view.backlog.notifier.reset_mock() 161 | 162 | form._subject_edit.set_edit_text(us_subject) 163 | 164 | signals.emit(form.save_button, "click") 165 | assert project_view.backlog.notifier.info_msg.call_count == 1 166 | assert (executor.update_user_story.call_args.call_list()[0][0][0]["id"] == form.user_story["id"]) 167 | assert executor.update_user_story.call_args.call_list()[0][0][1]["subject"] == us_subject 168 | assert executor.update_user_story.call_count == 1 169 | assert executor.update_user_story.return_value.result()["subject"] == us_subject 170 | 171 | def test_backlog_controller_move_user_story_down(): 172 | project = factories.project() 173 | project_view = views.projects.ProjectDetailView(project) 174 | project_view.backlog.notifier = mock.Mock() 175 | executor = factories.patched_executor() 176 | _ = mock.Mock() 177 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 178 | project_view.backlog.notifier.reset_mock() 179 | 180 | us_a_old = project_detail_controller.backlog.user_stories[0] 181 | us_b_old = project_detail_controller.backlog.user_stories[1] 182 | 183 | project_detail_controller.handle(settings.data.backlog.keys.decrease_priority) 184 | assert project_view.backlog.notifier.info_msg.call_count == 1 185 | 186 | us_b_new = project_detail_controller.backlog.user_stories[0] 187 | us_a_new = project_detail_controller.backlog.user_stories[1] 188 | 189 | assert us_a_old == us_a_new 190 | assert us_b_old == us_b_new 191 | 192 | def test_backlog_controller_move_user_story_up(): 193 | project = factories.project() 194 | project_view = views.projects.ProjectDetailView(project) 195 | project_view.backlog.notifier = mock.Mock() 196 | executor = factories.patched_executor() 197 | _ = mock.Mock() 198 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 199 | project_detail_controller.view.backlog.user_stories.widget.contents.focus = 2 200 | project_view.backlog.notifier.reset_mock() 201 | 202 | us_a_old = project_detail_controller.backlog.user_stories[0] 203 | us_b_old = project_detail_controller.backlog.user_stories[1] 204 | 205 | project_detail_controller.handle(settings.data.backlog.keys.increase_priority) 206 | assert project_view.backlog.notifier.info_msg.call_count == 1 207 | 208 | us_b_new = project_detail_controller.backlog.user_stories[0] 209 | us_a_new = project_detail_controller.backlog.user_stories[1] 210 | 211 | assert us_a_old == us_a_new 212 | assert us_b_old == us_b_new 213 | 214 | def test_backlog_controller_update_user_stories_order_with_errors(): 215 | project = factories.project() 216 | project_view = views.projects.ProjectDetailView(project) 217 | project_view.backlog.notifier = mock.Mock() 218 | executor = factories.patched_executor(update_user_stories_order_response=factories.future(None)) 219 | _ = mock.Mock() 220 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 221 | 222 | project_detail_controller.handle(settings.data.backlog.keys.update_order) 223 | assert project_view.backlog.notifier.error_msg.call_count == 1 224 | 225 | def test_backlog_controller_update_user_stories_order_with_success(): 226 | project = factories.project() 227 | project_view = views.projects.ProjectDetailView(project) 228 | project_view.backlog.notifier = mock.Mock() 229 | executor = factories.patched_executor() 230 | _ = mock.Mock() 231 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 232 | project_view.backlog.notifier.reset_mock() 233 | 234 | project_detail_controller.handle(settings.data.backlog.keys.update_order) 235 | assert project_view.backlog.notifier.info_msg.call_count == 1 236 | 237 | def test_backlog_controller_delete_user_story_with_errors(): 238 | project = factories.project() 239 | project_view = views.projects.ProjectDetailView(project) 240 | project_view.backlog.notifier = mock.Mock() 241 | executor = factories.patched_executor(delete_user_story_response=factories.future(None)) 242 | _ = mock.Mock() 243 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 244 | 245 | project_detail_controller.handle(settings.data.backlog.keys.delete) 246 | assert project_view.backlog.notifier.error_msg.call_count == 1 247 | assert (executor.delete_user_story.call_args.call_list()[0][0][0]["id"] == 248 | project_detail_controller.backlog.user_stories[0]["id"]) 249 | 250 | def test_backlog_controller_delete_user_story_with_success(): 251 | project = factories.project() 252 | project_view = views.projects.ProjectDetailView(project) 253 | project_view.backlog.notifier = mock.Mock() 254 | executor = factories.patched_executor() 255 | _ = mock.Mock() 256 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 257 | project_view.backlog.notifier.reset_mock() 258 | 259 | project_detail_controller.handle(settings.data.backlog.keys.delete) 260 | assert project_view.backlog.notifier.info_msg.call_count == 1 261 | assert (executor.delete_user_story.call_args.call_list()[0][0][0]["id"] == 262 | project_detail_controller.backlog.user_stories[0]["id"]) 263 | 264 | def test_backlog_controller_show_the_milestone_selector_popup(): 265 | project = factories.project() 266 | project_view = views.projects.ProjectDetailView(project) 267 | executor = factories.patched_executor() 268 | _ = mock.Mock() 269 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 270 | 271 | assert not hasattr(project_detail_controller.view.backlog, "milestone_selector_popup") 272 | project_detail_controller.handle(settings.data.backlog.keys.move_to_milestone) 273 | assert hasattr(project_detail_controller.view.backlog, "milestone_selector_popup") 274 | 275 | def test_backlog_controller_close_the_milestone_selector_popup(): 276 | project = factories.project() 277 | project_view = views.projects.ProjectDetailView(project) 278 | executor = factories.patched_executor() 279 | _ = mock.Mock() 280 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 281 | project_detail_controller.handle(settings.data.backlog.keys.move_to_milestone) 282 | 283 | assert hasattr(project_detail_controller.view.backlog, "milestone_selector_popup") 284 | milestone_selector_popup = project_detail_controller.view.backlog.milestone_selector_popup 285 | signals.emit(milestone_selector_popup.cancel_button, "click") 286 | assert not hasattr(project_detail_controller.view.backlog, "milestone_selector_popup") 287 | 288 | def test_backlog_controller_move_a_user_story_to_a_milestone(): 289 | project = factories.project() 290 | project_view = views.projects.ProjectDetailView(project) 291 | project_view.backlog.notifier = mock.Mock() 292 | executor = factories.patched_executor() 293 | _ = mock.Mock() 294 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 295 | project_detail_controller.handle(settings.data.backlog.keys.move_to_milestone) 296 | milestone_selector_popup = project_detail_controller.view.backlog.milestone_selector_popup 297 | project_view.backlog.notifier.reset_mock() 298 | 299 | assert project_view.backlog.notifier.info_msg.call_count == 0 300 | assert executor.update_user_story.call_count == 0 301 | signals.emit(milestone_selector_popup.options[2], "click") 302 | assert project_view.backlog.notifier.info_msg.call_count == 1 303 | assert executor.update_user_story.call_count == 1 304 | assert (executor.update_user_story.call_args.call_list()[0][0][1]["milestone"] == 305 | milestone_selector_popup.project["list_of_milestones"][-3]["id"]) 306 | 307 | def test_backlog_controller_change_user_story_status(): 308 | project = factories.project() 309 | project_view = views.projects.ProjectDetailView(project) 310 | project_view.backlog.notifier = mock.Mock() 311 | executor = factories.patched_executor() 312 | _ = mock.Mock() 313 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 314 | project_detail_controller.handle(settings.data.backlog.keys.edit) 315 | project_view.backlog.notifier.reset_mock() 316 | 317 | us = project_detail_controller.view.backlog.user_stories.widget.contents[1][0] 318 | combo = us.base_widget.widget.contents[5][0] # 5 => status 319 | item = combo.menu.get_item(0) # 0 => New 320 | combo.item_changed(item, True) 321 | 322 | assert project_view.backlog.notifier.info_msg.call_count == 1 323 | assert executor.update_user_story.call_args.call_list()[0][0][1]["status"] == item.value 324 | assert executor.update_user_story.call_count == 1 325 | 326 | def test_backlog_controller_change_user_story_points(): 327 | project = factories.project() 328 | project_view = views.projects.ProjectDetailView(project) 329 | project_view.backlog.notifier = mock.Mock() 330 | executor = factories.patched_executor() 331 | _ = mock.Mock() 332 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 333 | project_detail_controller.handle(settings.data.backlog.keys.edit) 334 | project_view.backlog.notifier.reset_mock() 335 | 336 | us = project_detail_controller.view.backlog.user_stories.widget.contents[1][0] 337 | combo = us.base_widget.widget.contents[6][0] # 6 => points 338 | item = combo.menu.get_item(2) # 2 => 1/2 339 | combo.item_changed(item, True) 340 | 341 | assert project_view.backlog.notifier.info_msg.call_count == 1 342 | assert list(executor.update_user_story.call_args.call_list()[0][0][1]["points"].values())[0] == item.value 343 | assert executor.update_user_story.call_count == 1 344 | 345 | # BULK 346 | 347 | def test_backlog_controller_show_the_new_user_stories_in_bulk_form(): 348 | project = factories.project() 349 | project_view = views.projects.ProjectDetailView(project) 350 | executor = factories.patched_executor() 351 | _ = mock.Mock() 352 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 353 | 354 | assert not hasattr(project_detail_controller.view.backlog, "user_stories_in_bulk_form") 355 | project_detail_controller.handle(settings.data.backlog.keys.create_in_bulk) 356 | assert hasattr(project_detail_controller.view.backlog, "user_stories_in_bulk_form") 357 | 358 | def test_backlog_controller_cancel_the_new_user_stories_in_bulk_form(): 359 | project = factories.project() 360 | project_view = views.projects.ProjectDetailView(project) 361 | executor = factories.patched_executor() 362 | _ = mock.Mock() 363 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 364 | project_detail_controller.handle(settings.data.backlog.keys.create_in_bulk) 365 | 366 | assert hasattr(project_detail_controller.view.backlog, "user_stories_in_bulk_form") 367 | form = project_detail_controller.view.backlog.user_stories_in_bulk_form 368 | signals.emit(form.cancel_button, "click") 369 | assert not hasattr(project_detail_controller.view.backlog, "user_stories_in_bulk_form") 370 | 371 | def test_backlog_controller_submit_new_user_stories_in_bulk_form_with_errors(): 372 | project = factories.project() 373 | project_view = views.projects.ProjectDetailView(project) 374 | project_view.backlog.notifier = mock.Mock() 375 | executor = factories.patched_executor() 376 | _ = mock.Mock() 377 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 378 | project_detail_controller.handle(settings.data.backlog.keys.create_in_bulk) 379 | form = project_detail_controller.view.backlog.user_stories_in_bulk_form 380 | 381 | signals.emit(form.save_button, "click") 382 | assert project_view.backlog.notifier.error_msg.call_count == 1 383 | 384 | def test_backlog_controller_submit_new_user_stories_in_bulk_form_successfully(): 385 | us_subjects = "Create a new user story 1\nCreate a new user story 2" 386 | project = factories.project() 387 | project_view = views.projects.ProjectDetailView(project) 388 | project_view.backlog.notifier = mock.Mock() 389 | executor = factories.patched_executor() 390 | _ = mock.Mock() 391 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 392 | project_detail_controller.handle(settings.data.backlog.keys.create_in_bulk) 393 | form = project_detail_controller.view.backlog.user_stories_in_bulk_form 394 | project_view.backlog.notifier.reset_mock() 395 | 396 | form._subjects_edit.set_edit_text(us_subjects) 397 | signals.emit(form.save_button, "click") 398 | assert project_view.backlog.notifier.info_msg.call_count == 1 399 | assert executor.create_user_stories_in_bulk.call_args.call_list()[0][0][0]["bulkStories"] == us_subjects 400 | assert executor.create_user_stories_in_bulk.call_count == 1 401 | assert executor.create_user_stories_in_bulk.return_value.result() 402 | -------------------------------------------------------------------------------- /tests/controllers/test_issues_controller.py: -------------------------------------------------------------------------------- 1 | from concurrent.futures import Future 2 | from unittest import mock 3 | 4 | from taiga_ncurses.ui import signals, views 5 | from taiga_ncurses import controllers 6 | from taiga_ncurses.config import settings 7 | from taiga_ncurses.executor import Executor 8 | from taiga_ncurses.core import StateMachine 9 | 10 | from tests import factories 11 | 12 | 13 | def test_issues_controller_show_the_filters_popup(): 14 | project = factories.project() 15 | project_view = views.projects.ProjectDetailView(project) 16 | executor = factories.patched_executor() 17 | _ = mock.Mock() 18 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 19 | project_detail_controller.handle(settings.data.main.keys.issues) 20 | 21 | assert not hasattr(project_detail_controller.view.issues, "filters_popup") 22 | project_detail_controller.handle(settings.data.issues.keys.filters) 23 | assert hasattr(project_detail_controller.view.issues, "filters_popup") 24 | 25 | def test_issues_controller_submit_the_filters_popup(): 26 | project = factories.project() 27 | project_view = views.projects.ProjectDetailView(project) 28 | project_view.issues.notifier = mock.Mock() 29 | executor = factories.patched_executor() 30 | _ = mock.Mock() 31 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 32 | project_detail_controller.handle(settings.data.main.keys.issues) 33 | project_detail_controller.handle(settings.data.issues.keys.filters) 34 | filters_popup = project_detail_controller.view.issues.filters_popup 35 | project_view.issues.notifier.reset_mock() 36 | executor.issues.reset_mock() 37 | 38 | assert project_view.issues.notifier.info_msg.call_count == 0 39 | assert executor.issues.call_count == 0 40 | filters_popup._issue_types_group[0].set_state(True) 41 | filters_popup._issue_statuses_group[0].set_state(True) 42 | filters_popup._priorities_group[0].set_state(True) 43 | filters_popup._severities_group[0].set_state(True) 44 | filters_popup._assigned_to_group[0].set_state(True) 45 | filters_popup._created_by_group[0].set_state(True) 46 | #filters_popup._tags_group[0].set_state(True) 47 | signals.emit(filters_popup.filter_button, "click") 48 | assert project_view.issues.notifier.info_msg.call_count == 1 49 | assert executor.issues.call_count == 1 50 | assert len(filters_popup._filters) == 7 51 | assert len(executor.issues.call_args.call_list()[0][1]["filters"]["type"]) == 1 52 | assert len(executor.issues.call_args.call_list()[0][1]["filters"]["status"]) == 1 53 | assert len(executor.issues.call_args.call_list()[0][1]["filters"]["severity"]) == 1 54 | assert len(executor.issues.call_args.call_list()[0][1]["filters"]["priority"]) == 1 55 | assert len(executor.issues.call_args.call_list()[0][1]["filters"]["assigned_to"]) == 1 56 | assert len(executor.issues.call_args.call_list()[0][1]["filters"]["owner"]) == 1 57 | #assert len(executor.issues.call_args.call_list()[0][1]["filters"]["tags"]) == 1 58 | 59 | def test_issues_controller_cancel_the_filters_popup(): 60 | project = factories.project() 61 | project_view = views.projects.ProjectDetailView(project) 62 | executor = factories.patched_executor() 63 | _ = mock.Mock() 64 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 65 | project_detail_controller.handle(settings.data.main.keys.issues) 66 | project_detail_controller.handle(settings.data.issues.keys.filters) 67 | 68 | assert hasattr(project_detail_controller.view.issues, "filters_popup") 69 | filters_popup = project_detail_controller.view.issues.filters_popup 70 | signals.emit(filters_popup.cancel_button, "click") 71 | assert not hasattr(project_detail_controller.view.issues, "filters_popup") 72 | 73 | def test_issues_controller_show_the_help_popup(): 74 | project = factories.project() 75 | project_view = views.projects.ProjectDetailView(project) 76 | executor = factories.patched_executor() 77 | _ = mock.Mock() 78 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 79 | project_detail_controller.handle(settings.data.main.keys.issues) 80 | 81 | assert not hasattr(project_detail_controller.view.issues, "help_popup") 82 | project_detail_controller.handle(settings.data.issues.keys.help) 83 | assert hasattr(project_detail_controller.view.issues, "help_popup") 84 | 85 | def test_issues_controller_close_the_help_popup(): 86 | project = factories.project() 87 | project_view = views.projects.ProjectDetailView(project) 88 | executor = factories.patched_executor() 89 | _ = mock.Mock() 90 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 91 | project_detail_controller.handle(settings.data.main.keys.issues) 92 | project_detail_controller.handle(settings.data.issues.keys.help) 93 | 94 | assert hasattr(project_detail_controller.view.issues, "help_popup") 95 | help_popup = project_detail_controller.view.issues.help_popup 96 | signals.emit(help_popup.close_button, "click") 97 | assert not hasattr(project_detail_controller.view.issues, "help_popup") 98 | 99 | def test_issues_controller_reload(): 100 | project = factories.project() 101 | project_view = views.projects.ProjectDetailView(project) 102 | executor = factories.patched_executor() 103 | _ = mock.Mock() 104 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 105 | project_detail_controller.handle(settings.data.main.keys.issues) 106 | executor.project_issues_stats.reset_mock() 107 | executor.issues.reset_mock() 108 | 109 | assert executor.project_issues_stats.call_count == 0 110 | assert executor.issues.call_count == 0 111 | project_detail_controller.handle(settings.data.issues.keys.reload) 112 | assert executor.project_issues_stats.call_count == 1 113 | assert executor.issues.call_count == 1 114 | 115 | def test_issues_controller_show_the_new_issue_form(): 116 | project = factories.project() 117 | project_view = views.projects.ProjectDetailView(project) 118 | executor = factories.patched_executor() 119 | _ = mock.Mock() 120 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 121 | project_detail_controller.handle(settings.data.main.keys.issues) 122 | 123 | assert not hasattr(project_detail_controller.view.issues, "issue_form") 124 | project_detail_controller.handle(settings.data.issues.keys.create) 125 | assert hasattr(project_detail_controller.view.issues, "issue_form") 126 | 127 | def test_issues_controller_cancel_the_new_issue_form(): 128 | project = factories.project() 129 | project_view = views.projects.ProjectDetailView(project) 130 | executor = factories.patched_executor() 131 | _ = mock.Mock() 132 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 133 | project_detail_controller.handle(settings.data.main.keys.issues) 134 | project_detail_controller.handle(settings.data.issues.keys.create) 135 | 136 | assert hasattr(project_detail_controller.view.issues, "issue_form") 137 | form = project_detail_controller.view.issues.issue_form 138 | signals.emit(form.cancel_button, "click") 139 | assert not hasattr(project_detail_controller.view.issues, "issue_form") 140 | 141 | def test_issues_controller_submit_new_issue_form_with_errors(): 142 | project = factories.project() 143 | project_view = views.projects.ProjectDetailView(project) 144 | project_view.issues.notifier = mock.Mock() 145 | executor = factories.patched_executor() 146 | _ = mock.Mock() 147 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 148 | project_detail_controller.handle(settings.data.main.keys.issues) 149 | project_detail_controller.handle(settings.data.issues.keys.create) 150 | form = project_detail_controller.view.issues.issue_form 151 | 152 | signals.emit(form.save_button, "click") 153 | assert project_view.issues.notifier.error_msg.call_count == 1 154 | 155 | def test_issues_controller_submit_new_issue_form_successfully(): 156 | issue_subject = "Create a new issue" 157 | project = factories.project() 158 | project_view = views.projects.ProjectDetailView(project) 159 | project_view.issues.notifier = mock.Mock() 160 | executor = factories.patched_executor(create_issue_response=factories.future( 161 | factories.successful_create_issue_response(issue_subject))) 162 | _ = mock.Mock() 163 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 164 | project_detail_controller.handle(settings.data.main.keys.issues) 165 | project_detail_controller.handle(settings.data.issues.keys.create) 166 | form = project_detail_controller.view.issues.issue_form 167 | project_view.issues.notifier.reset_mock() 168 | 169 | form._subject_edit.set_edit_text(issue_subject) 170 | signals.emit(form.save_button, "click") 171 | assert project_view.issues.notifier.info_msg.call_count == 1 172 | assert executor.create_issue.call_args.call_list()[0][0][0]["subject"] == issue_subject 173 | assert executor.create_issue.call_count == 1 174 | assert executor.create_issue.return_value.result()["subject"] == issue_subject 175 | 176 | def test_issues_controller_show_the_edit_issue_form(): 177 | project = factories.project() 178 | project_view = views.projects.ProjectDetailView(project) 179 | executor = factories.patched_executor() 180 | _ = mock.Mock() 181 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 182 | project_detail_controller.handle(settings.data.main.keys.issues) 183 | 184 | assert not hasattr(project_detail_controller.view.issues, "issue_form") 185 | project_detail_controller.handle(settings.data.issues.keys.edit) 186 | assert hasattr(project_detail_controller.view.issues, "issue_form") 187 | assert (project_detail_controller.view.issues.issue_form.issue == 188 | project_detail_controller.view.issues.issues.list_walker.get_focus()[0].issue) 189 | 190 | def test_issues_controller_cancel_the_edit_issue_form(): 191 | project = factories.project() 192 | project_view = views.projects.ProjectDetailView(project) 193 | executor = factories.patched_executor() 194 | _ = mock.Mock() 195 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 196 | project_detail_controller.handle(settings.data.main.keys.issues) 197 | project_detail_controller.handle(settings.data.issues.keys.edit) 198 | 199 | assert hasattr(project_detail_controller.view.issues, "issue_form") 200 | form = project_detail_controller.view.issues.issue_form 201 | signals.emit(form.cancel_button, "click") 202 | assert not hasattr(project_detail_controller.view.issues, "issue_form") 203 | 204 | def test_issues_controller_submit_the_edit_issue_form_with_errors(): 205 | project = factories.project() 206 | project_view = views.projects.ProjectDetailView(project) 207 | project_view.issues.notifier = mock.Mock() 208 | executor = factories.patched_executor() 209 | _ = mock.Mock() 210 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 211 | project_detail_controller.handle(settings.data.main.keys.issues) 212 | project_detail_controller.handle(settings.data.issues.keys.edit) 213 | form = project_detail_controller.view.issues.issue_form 214 | 215 | form._subject_edit.set_edit_text("") 216 | signals.emit(form.save_button, "click") 217 | assert project_view.issues.notifier.error_msg.call_count == 1 218 | 219 | def test_issues_controller_submit_edit_issue_form_successfully(): 220 | issue_subject = "Update a issue" 221 | project = factories.project() 222 | project_view = views.projects.ProjectDetailView(project) 223 | project_view.issues.notifier = mock.Mock() 224 | executor = factories.patched_executor(update_issue_response=factories.future( 225 | factories.successful_update_issue_response(issue_subject))) 226 | _ = mock.Mock() 227 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 228 | project_detail_controller.handle(settings.data.main.keys.issues) 229 | project_detail_controller.handle(settings.data.issues.keys.edit) 230 | form = project_detail_controller.view.issues.issue_form 231 | project_view.issues.notifier.reset_mock() 232 | 233 | form._subject_edit.set_edit_text(issue_subject) 234 | 235 | signals.emit(form.save_button, "click") 236 | assert project_view.issues.notifier.info_msg.call_count == 1 237 | assert (executor.update_issue.call_args.call_list()[0][0][0]["id"] == form.issue["id"]) 238 | assert executor.update_issue.call_args.call_list()[0][0][1]["subject"] == issue_subject 239 | assert executor.update_issue.call_count == 1 240 | assert executor.update_issue.return_value.result()["subject"] == issue_subject 241 | 242 | def test_issues_controller_delete_issue_with_errors(): 243 | project = factories.project() 244 | project_view = views.projects.ProjectDetailView(project) 245 | project_view.issues.notifier = mock.Mock() 246 | executor = factories.patched_executor(delete_issue_response=factories.future(None)) 247 | _ = mock.Mock() 248 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 249 | project_detail_controller.handle(settings.data.main.keys.issues) 250 | 251 | project_detail_controller.handle(settings.data.issues.keys.delete) 252 | assert project_view.issues.notifier.error_msg.call_count == 1 253 | assert (executor.delete_issue.call_args.call_list()[0][0][0]["id"] == 254 | project_detail_controller.issues.issues[0]["id"]) 255 | 256 | def test_issues_controller_delete_issue_with_success(): 257 | project = factories.project() 258 | project_view = views.projects.ProjectDetailView(project) 259 | project_view.issues.notifier = mock.Mock() 260 | executor = factories.patched_executor() 261 | _ = mock.Mock() 262 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 263 | project_detail_controller.handle(settings.data.main.keys.issues) 264 | project_view.issues.notifier.reset_mock() 265 | 266 | project_detail_controller.handle(settings.data.issues.keys.delete) 267 | assert project_view.issues.notifier.info_msg.call_count == 1 268 | assert (executor.delete_issue.call_args.call_list()[0][0][0]["id"] == 269 | project_detail_controller.issues.issues[0]["id"]) 270 | 271 | def test_issues_controller_change_issue_status(): 272 | project = factories.project() 273 | project_view = views.projects.ProjectDetailView(project) 274 | project_view.issues.notifier = mock.Mock() 275 | executor = factories.patched_executor() 276 | _ = mock.Mock() 277 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 278 | project_detail_controller.handle(settings.data.main.keys.issues) 279 | project_view.issues.notifier.reset_mock() 280 | 281 | issue = project_view.issues.issues.widget.contents()[0][0] 282 | combo = issue.base_widget.widget.contents[1][0] # 1 => status 283 | item = combo.menu.get_item(0) # 0 => New 284 | combo.item_changed(item, True) 285 | 286 | assert project_view.issues.notifier.info_msg.call_count == 1 287 | assert executor.update_issue.call_args.call_list()[0][0][1]["status"] == item.value 288 | assert executor.update_issue.call_count == 1 289 | 290 | def test_issues_controller_change_issue_priority(): 291 | project = factories.project() 292 | project_view = views.projects.ProjectDetailView(project) 293 | project_view.issues.notifier = mock.Mock() 294 | executor = factories.patched_executor() 295 | _ = mock.Mock() 296 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 297 | project_detail_controller.handle(settings.data.main.keys.issues) 298 | project_view.issues.notifier.reset_mock() 299 | 300 | issue = project_view.issues.issues.widget.contents()[0][0] 301 | combo = issue.base_widget.widget.contents[2][0] # 2 => priority 302 | item = combo.menu.get_item(0) # 0 => Low 303 | combo.item_changed(item, True) 304 | 305 | assert project_view.issues.notifier.info_msg.call_count == 1 306 | assert executor.update_issue.call_args.call_list()[0][0][1]["priority"] == item.value 307 | assert executor.update_issue.call_count == 1 308 | 309 | def test_issues_controller_change_issue_severity(): 310 | project = factories.project() 311 | project_view = views.projects.ProjectDetailView(project) 312 | project_view.issues.notifier = mock.Mock() 313 | executor = factories.patched_executor() 314 | _ = mock.Mock() 315 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 316 | project_detail_controller.handle(settings.data.main.keys.issues) 317 | project_view.issues.notifier.reset_mock() 318 | 319 | issue = project_view.issues.issues.widget.contents()[0][0] 320 | combo = issue.base_widget.widget.contents[3][0] # 3 => severity 321 | item = combo.menu.get_item(0) # 0 => wishlist 322 | combo.item_changed(item, True) 323 | 324 | assert project_view.issues.notifier.info_msg.call_count == 1 325 | assert executor.update_issue.call_args.call_list()[0][0][1]["severity"] == item.value 326 | assert executor.update_issue.call_count == 1 327 | 328 | def test_issues_controller_change_issue_assigned_to(): 329 | project = factories.project() 330 | project_view = views.projects.ProjectDetailView(project) 331 | project_view.issues.notifier = mock.Mock() 332 | executor = factories.patched_executor() 333 | _ = mock.Mock() 334 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, _) 335 | project_detail_controller.handle(settings.data.main.keys.issues) 336 | project_view.issues.notifier.reset_mock() 337 | 338 | issue = project_view.issues.issues.widget.contents()[0][0] 339 | combo = issue.base_widget.widget.contents[4][0] # 4 => assigned_to 340 | item = combo.menu.get_item(0) # 0 341 | combo.item_changed(item, True) 342 | 343 | assert project_view.issues.notifier.info_msg.call_count == 1 344 | assert executor.update_issue.call_args.call_list()[0][0][1]["assigned_to"] == item.value 345 | assert executor.update_issue.call_count == 1 346 | -------------------------------------------------------------------------------- /tests/controllers/test_project_controller.py: -------------------------------------------------------------------------------- 1 | from concurrent.futures import Future 2 | from unittest import mock 3 | 4 | from taiga_ncurses.ui import signals, views 5 | from taiga_ncurses import controllers 6 | from taiga_ncurses.config import settings 7 | from taiga_ncurses.executor import Executor 8 | from taiga_ncurses.core import StateMachine 9 | 10 | from tests import factories 11 | 12 | 13 | # AUTH 14 | 15 | def test_when_clicking_login_button_controllers_handle_login_method_is_called(): 16 | login_view = factories.login_view("", "") 17 | _ = mock.Mock() 18 | login_controller = controllers.auth.LoginController(login_view, _, _) 19 | login_controller.handle_login_request = mock.Mock() 20 | signals.emit(login_view.login_button, "click") 21 | assert login_controller.handle_login_request.call_count == 1 22 | 23 | def test_login_controller_prints_an_error_message_on_unsuccessful_login(): 24 | login_view = factories.login_view("admin", "123123") 25 | login_view.notifier = mock.Mock() 26 | executor = factories.patched_executor(login_response=factories.future(None)) 27 | _ = mock.Mock() 28 | login_controller = controllers.auth.LoginController(login_view, executor, _) 29 | 30 | signals.emit(login_view.login_button, "click") 31 | 32 | assert login_view.notifier.error_msg.call_count == 1 33 | 34 | # PROJECTS 35 | 36 | def test_login_controller_transitions_to_projects_on_successful_login(): 37 | username, password = "admin", "123123" 38 | login_view = factories.login_view(username, password) 39 | 40 | resp = Future() 41 | resp.set_result(factories.successful_login_response(username)) 42 | f = mock.Mock() 43 | f.add_done_callback = lambda f: f(resp) 44 | executor = mock.Mock() 45 | executor.login = mock.Mock(return_value=f) 46 | state_machine = mock.Mock() 47 | login_controller = controllers.auth.LoginController(login_view, executor, state_machine) 48 | 49 | signals.emit(login_view.login_button, "click") 50 | 51 | assert state_machine.logged_in.call_count == 1 52 | 53 | def test_projects_controller_click_on_project_requests_the_project_detail(): 54 | projects = factories.projects() 55 | projects_view = views.projects.ProjectsView() 56 | executor = factories.patched_executor() 57 | _ = mock.Mock() 58 | projects_controller = controllers.projects.ProjectsController(projects_view, executor, _) 59 | 60 | signals.emit(projects_view.project_buttons[0], "click") 61 | 62 | executor.project_detail.assert_called_with(projects[0]) 63 | 64 | def test_projects_controller_when_requesting_a_project_info_message_is_shown(): 65 | projects = factories.projects() 66 | projects_view = views.projects.ProjectsView() 67 | projects_view.notifier = mock.Mock() 68 | executor = factories.patched_executor() 69 | _ = mock.Mock() 70 | projects_controller = controllers.projects.ProjectsController(projects_view, executor, _) 71 | 72 | signals.emit(projects_view.project_buttons[0], "click") 73 | 74 | assert projects_view.notifier.info_msg.call_count == 1 75 | 76 | def test_projects_controller_click_on_project_when_project_is_fetched_transitions_to_project_detail(): 77 | projects = factories.projects() 78 | fetched_project = projects[0] 79 | projects_view = views.projects.ProjectsView() 80 | executor = factories.patched_executor(project_detail=factories.future(fetched_project)) 81 | state_machine = mock.Mock() 82 | projects_controller = controllers.projects.ProjectsController(projects_view, executor, state_machine) 83 | 84 | signals.emit(projects_view.project_buttons[0], "click") 85 | 86 | state_machine.project_detail.assert_called_with(fetched_project) 87 | 88 | def test_projects_controller_when_project_fetching_fails_a_error_message_is_shown(): 89 | projects = factories.projects() 90 | fetched_project = projects[0] 91 | projects_view = views.projects.ProjectsView() 92 | projects_view.notifier = mock.Mock() 93 | executor = factories.patched_executor(project_detail=factories.future(None)) 94 | _ = mock.Mock() 95 | projects_controller = controllers.projects.ProjectsController(projects_view, executor, _) 96 | 97 | signals.emit(projects_view.project_buttons[0], "click") 98 | 99 | assert projects_view.notifier.error_msg.call_count == 1 100 | 101 | def test_project_detail_controller_fetches_user_stories_and_transitions_to_backlog(): 102 | project = factories.project() 103 | project_view = views.projects.ProjectDetailView(project) 104 | executor = factories.patched_executor() 105 | state_machine = StateMachine(mock.Mock(), StateMachine.PROJECTS) 106 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, 107 | state_machine) 108 | 109 | assert state_machine.state == state_machine.PROJECT_BACKLOG 110 | 111 | def test_project_detail_controller_fetches_issues_and_transitions_to_issues(): 112 | project = factories.project() 113 | project_view = views.projects.ProjectDetailView(project) 114 | executor = factories.patched_executor() 115 | state_machine = StateMachine(mock.Mock(), StateMachine.PROJECTS) 116 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, 117 | state_machine) 118 | assert state_machine.state == state_machine.PROJECT_BACKLOG 119 | 120 | project_detail_controller.handle(settings.data.main.keys.issues) 121 | assert state_machine.state == state_machine.PROJECT_ISSUES 122 | 123 | def test_project_detail_controller_fetches_task_and_transitions_to_sprint_taskboard(): 124 | project = factories.project() 125 | project_view = views.projects.ProjectDetailView(project) 126 | executor = factories.patched_executor() 127 | state_machine = StateMachine(mock.Mock(), StateMachine.PROJECTS) 128 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, 129 | state_machine) 130 | assert state_machine.state == state_machine.PROJECT_BACKLOG 131 | 132 | project_detail_controller.handle(settings.data.main.keys.milestone) 133 | assert state_machine.state == state_machine.PROJECT_MILESTONES 134 | 135 | def test_project_detail_controller_fetches_wiki_pages_and_transitions_to_wiki(): 136 | project = factories.project() 137 | project_view = views.projects.ProjectDetailView(project) 138 | executor = factories.patched_executor() 139 | state_machine = StateMachine(mock.Mock(), StateMachine.PROJECTS) 140 | project_detail_controller = controllers.projects.ProjectDetailController(project_view, executor, 141 | state_machine) 142 | assert state_machine.state == state_machine.PROJECT_BACKLOG 143 | 144 | project_detail_controller.handle(settings.data.main.keys.wiki) 145 | assert state_machine.state == state_machine.PROJECT_WIKI 146 | -------------------------------------------------------------------------------- /tests/controllers/test_wiki_controller.py: -------------------------------------------------------------------------------- 1 | from concurrent.futures import Future 2 | from unittest import mock 3 | 4 | from taiga_ncurses.ui import signals, views 5 | from taiga_ncurses import controllers, config 6 | from taiga_ncurses.executor import Executor 7 | from taiga_ncurses.core import StateMachine 8 | 9 | from tests import factories 10 | 11 | 12 | -------------------------------------------------------------------------------- /tests/factories.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | from concurrent.futures import Future 3 | import json 4 | 5 | from taiga_ncurses.ui import views, signals 6 | from taiga_ncurses.executor import Executor 7 | 8 | from . import fixtures 9 | 10 | # Auth 11 | def login_view(username, password): 12 | login_view = views.auth.LoginView("username", "password") 13 | login_view._username_editor.set_edit_text(username) 14 | login_view._password_editor.set_edit_text(password) 15 | return login_view 16 | 17 | def successful_login_response(username): 18 | return { 19 | 'auth_token': 'eyJ1c2VyX2lkIjoxfQ:1Vmjdp:ILIJVRazEdK_pObFedQc2aZNWd0', 20 | 'color': '', 21 | 'default_language': '', 22 | 'default_timezone': '', 23 | 'description': '', 24 | 'email': 'niwi@niwi.be', 25 | 'first_name': '', 26 | 'full_name': 'admin', 27 | 'id': 1, 28 | 'is_active': True, 29 | 'last_name': '', 30 | 'notify_changes_by_me': False, 31 | 'notify_level': 'all_owned_projects', 32 | 'photo': '', 33 | 'projects': [], 34 | 'username': username, 35 | } 36 | 37 | # Projects 38 | def projects(): 39 | return json.loads(fixtures.PROJECTS) 40 | 41 | def project(**kwargs): 42 | defaults = json.loads(fixtures.PROJECT) 43 | defaults.update(kwargs) 44 | return defaults 45 | 46 | def project_stats(): 47 | return json.loads(fixtures.PROJECT_STATS) 48 | 49 | def project_issues_stats(): 50 | return json.loads(fixtures.PROJECT_ISSUES_STATS) 51 | 52 | # Milestones 53 | def milestone(): 54 | return json.loads(fixtures.MILESTONE) 55 | 56 | def milestone_stats(): 57 | return json.loads(fixtures.MILESTONE_STATS) 58 | 59 | # User Stories 60 | def unassigned_user_stories(): 61 | return json.loads(fixtures.UNASSIGNED_USER_STORIES) 62 | 63 | def user_stories(): 64 | return json.loads(fixtures.USER_STORIES) 65 | 66 | def successful_create_user_story_response(subject): 67 | return { 68 | "tags": [], 69 | "points": {"4": 1, "1": 1, "2": 1, "3": 1}, 70 | "total_points": 0.0, 71 | "comment": "", 72 | "id": 114, 73 | "ref": 30, 74 | "milestone": None, 75 | "project": 1, 76 | "owner": 1, 77 | "status": 1, 78 | "is_closed": False, 79 | "order": 100, 80 | "created_date": "2013-12-31T16:56:38.115Z", 81 | "modified_date": "2013-12-31T16:56:38.115Z", 82 | "finish_date": None, 83 | "subject": subject, 84 | "description": "", 85 | "client_requirement": False, 86 | "team_requirement": False, 87 | "watchers": [] 88 | } 89 | 90 | def successful_update_user_story_response(subject): 91 | return { 92 | "tags": [], 93 | "points": {"4": 1, "1": 1, "2": 1, "3": 1}, 94 | "total_points": 0.0, 95 | "comment": "", 96 | "id": 114, 97 | "ref": 30, 98 | "milestone": None, 99 | "project": 1, 100 | "owner": 1, 101 | "status": 1, 102 | "is_closed": False, 103 | "order": 100, 104 | "created_date": "2013-12-31T16:56:38.115Z", 105 | "modified_date": "2013-12-31T16:56:38.115Z", 106 | "finish_date": None, 107 | "subject": subject, 108 | "description": "", 109 | "client_requirement": False, 110 | "team_requirement": False, 111 | "watchers": [] 112 | } 113 | 114 | def successful_create_user_stories_in_bulk_response(): 115 | return True 116 | 117 | def successful_update_user_stories_order_response(): 118 | return True 119 | 120 | def successful_delete_user_story_response(): 121 | return True 122 | 123 | # Tasks 124 | def tasks(): 125 | return json.loads(fixtures.MILESTONE_TASKS) 126 | 127 | def successful_create_task_response(subject, user_story): 128 | return { 129 | "tags": "", 130 | "comment": "", 131 | "id": 35, 132 | "user_story": user_story, 133 | "ref": 36, 134 | "owner": 3, 135 | "status": 1, 136 | "project": 1, 137 | "milestone": 4, 138 | "created_date": "2013-12-20T09:53:53.462Z", 139 | "modified_date": "2013-12-26T16:54:54.931Z", 140 | "finished_date": None, 141 | "subject": subject, 142 | "description": "Praesentium tempora molestias quis autem iste. Esse perspiciatis eos odio nemo, accusamus adipisci doloremque nesciunt temporibus consequatur dolore tempora dolorum, necessitatibus fugiat non veniam mollitia adipisci nesciunt quibusdam accusamus quidem quis consequuntur, error sunt fugit dolorem suscipit, rem numquam dicta nemo sapiente.", 143 | "assigned_to": 9, 144 | "is_iocaine": False, 145 | "watchers": [] 146 | } 147 | 148 | def successful_update_task_response(subject, user_story): 149 | return { 150 | "tags": "", 151 | "comment": "", 152 | "id": 35, 153 | "user_story": user_story, 154 | "ref": 36, 155 | "owner": 3, 156 | "status": 1, 157 | "project": 1, 158 | "milestone": 4, 159 | "created_date": "2013-12-20T09:53:53.462Z", 160 | "modified_date": "2013-12-26T16:54:54.931Z", 161 | "finished_date": None, 162 | "subject": subject, 163 | "description": "Praesentium tempora molestias quis autem iste. Esse perspiciatis eos odio nemo, accusamus adipisci doloremque nesciunt temporibus consequatur dolore tempora dolorum, necessitatibus fugiat non veniam mollitia adipisci nesciunt quibusdam accusamus quidem quis consequuntur, error sunt fugit dolorem suscipit, rem numquam dicta nemo sapiente.", 164 | "assigned_to": 9, 165 | "is_iocaine": False, 166 | "watchers": [] 167 | } 168 | 169 | def successful_delete_task_response(): 170 | return True 171 | 172 | # Issues 173 | def issues(): 174 | return json.loads(fixtures.ISSUES) 175 | 176 | def successful_create_issue_response(subject): 177 | return { 178 | "tags": [ 179 | "ratione", 180 | "omnis", 181 | "saepe", 182 | "tempora", 183 | "repellat" 184 | ], 185 | "comment": "", 186 | "is_closed": False, 187 | "id": 1, 188 | "ref": 2, 189 | "owner": 2, 190 | "status": 7, 191 | "severity": 5, 192 | "priority": 2, 193 | "type": 2, 194 | "milestone": None, 195 | "project": 1, 196 | "created_date": "2013-12-20T09:53:59.044Z", 197 | "modified_date": "2013-12-20T09:53:59.609Z", 198 | "finished_date": None, 199 | "subject": subject, 200 | "description": "Alias voluptatem nulla quo reiciendis dicta distinctio, quis vel facilis quae dolore rerum earum error nesciunt, ipsam itaque eius placeat doloribus voluptate sequi? Impedit iure adipisci et itaque debitis nihil vel ipsum esse ut perspiciatis. Facilis fuga exercitationem illo ipsam eveniet, tempora assumenda voluptate, tenetur saepe doloribus beatae neque quae quasi culpa reprehenderit et, totam temporibus deleniti consectetur rerum quis eaque commodi.", 201 | "assigned_to": 1, 202 | "watchers": [] 203 | } 204 | 205 | def successful_update_issue_response(subject): 206 | return { 207 | "tags": [ 208 | "ratione", 209 | "omnis", 210 | "saepe", 211 | "tempora", 212 | "repellat" 213 | ], 214 | "comment": "", 215 | "is_closed": False, 216 | "id": 1, 217 | "ref": 2, 218 | "owner": 2, 219 | "status": 7, 220 | "severity": 5, 221 | "priority": 2, 222 | "type": 2, 223 | "milestone": None, 224 | "project": 1, 225 | "created_date": "2013-12-20T09:53:59.044Z", 226 | "modified_date": "2013-12-20T09:53:59.609Z", 227 | "finished_date": None, 228 | "subject": subject, 229 | "description": "Alias voluptatem nulla quo reiciendis dicta distinctio, quis vel facilis quae dolore rerum earum error nesciunt, ipsam itaque eius placeat doloribus voluptate sequi? Impedit iure adipisci et itaque debitis nihil vel ipsum esse ut perspiciatis. Facilis fuga exercitationem illo ipsam eveniet, tempora assumenda voluptate, tenetur saepe doloribus beatae neque quae quasi culpa reprehenderit et, totam temporibus deleniti consectetur rerum quis eaque commodi.", 230 | "assigned_to": 1, 231 | "watchers": [] 232 | } 233 | 234 | def successful_delete_issue_response(): 235 | return True 236 | 237 | # Wiki 238 | def wiki_pages(): 239 | return json.loads(fixtures.WIKI_PAGES) 240 | 241 | def future(value): 242 | f = Future() 243 | f.set_result(value) 244 | return f 245 | 246 | def patched_executor(login_response=future(successful_login_response("admin")), 247 | projects=future(projects()), 248 | project_detail=future(project()), 249 | project_stats=future(project_stats()), 250 | unassigned_user_stories=future(unassigned_user_stories()), 251 | milestone=future(milestone()), 252 | milestone_stats=future(milestone_stats()), 253 | user_stories=future(user_stories()), 254 | create_user_story_response=future(successful_create_user_story_response("Create us")), 255 | update_user_story_response=future(successful_update_user_story_response("Update us")), 256 | create_user_stories_in_bulk_response=future( 257 | successful_create_user_stories_in_bulk_response()), 258 | update_user_stories_order_response=future(successful_update_user_stories_order_response()), 259 | delete_user_story_response=future(successful_delete_user_story_response()), 260 | tasks=future(tasks()), 261 | create_task_response=future(successful_create_task_response("Create task", 1)), 262 | update_task_response=future(successful_update_task_response("Update task", 1)), 263 | delete_task_response=future(successful_delete_task_response()), 264 | project_issues_stats=future(project_issues_stats()), 265 | issues=future(issues()), 266 | create_issue_response=future(successful_create_issue_response("Create issue")), 267 | update_issue_response=future(successful_update_issue_response("Update issue")), 268 | delete_issue_response=future(successful_delete_issue_response()), 269 | wiki_pages=future(wiki_pages())): 270 | executor = Executor(mock.Mock()) 271 | 272 | executor.login = mock.Mock(return_value=login_response) 273 | 274 | executor.projects = mock.Mock(return_value=projects) 275 | executor.project_detail = mock.Mock(return_value=project_detail) 276 | executor.project_stats = mock.Mock(return_value=project_stats) 277 | executor.project_issues_stats = mock.Mock(return_value=project_issues_stats) 278 | 279 | executor.user_stories = mock.Mock(return_value=user_stories) 280 | executor.unassigned_user_stories = mock.Mock(return_value=unassigned_user_stories) 281 | executor.create_user_story = mock.Mock(return_value=create_user_story_response) 282 | executor.update_user_story = mock.Mock(return_value=update_user_story_response) 283 | executor.create_user_stories_in_bulk = mock.Mock(return_value=create_user_stories_in_bulk_response) 284 | executor.update_user_stories_order = mock.Mock(return_value=update_user_stories_order_response) 285 | executor.delete_user_story = mock.Mock(return_value=delete_user_story_response) 286 | 287 | executor.milestone = mock.Mock(return_value=milestone) 288 | executor.milestone_stats = mock.Mock(return_value=milestone_stats) 289 | 290 | executor.tasks = mock.Mock(return_value=tasks) 291 | executor.create_task = mock.Mock(return_value=create_task_response) 292 | executor.update_task = mock.Mock(return_value=update_task_response) 293 | executor.delete_task = mock.Mock(return_value=delete_task_response) 294 | 295 | executor.issues = mock.Mock(return_value=issues) 296 | executor.create_issue = mock.Mock(return_value=create_issue_response) 297 | executor.update_issue = mock.Mock(return_value=update_issue_response) 298 | executor.delete_issue = mock.Mock(return_value=delete_issue_response) 299 | 300 | executor.wiki_pages = mock.Mock(return_value=wiki_pages) 301 | 302 | return executor 303 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | 4 | ### TODO 5 | # 6 | #from taiga_ncurses.config import DEFAULTS, Configuration 7 | # 8 | # 9 | #SAMPLE_HOST = { 10 | # "scheme": "http", 11 | # "domain": "localhost", 12 | # "port": 8000 13 | #} 14 | # 15 | #SAMPLE_CONFIG = """ 16 | #[host] 17 | #scheme = {scheme} 18 | #domain = {domain} 19 | #port = {port} 20 | # 21 | #[site] 22 | #domain = {domain} 23 | #""".format(**SAMPLE_HOST) 24 | # 25 | # 26 | #SAMPLE_AUTH = { 27 | # "token": "42", 28 | #} 29 | # 30 | #SAMPLE_AUTH_CONFIG = """ 31 | #[auth] 32 | #token = {token} 33 | #""".format(**SAMPLE_AUTH) 34 | # 35 | #def test_configuration_builds_a_url_for_the_host(): 36 | # _ = tempfile.NamedTemporaryFile() 37 | # config = Configuration(config_file=_) 38 | # _.close() 39 | # assert config.host == "{scheme}://{domain}:{port}".format(scheme=DEFAULTS["main"]["host"]["scheme"], 40 | # domain=DEFAULTS["main"]["host"]["domain"], 41 | # port=DEFAULTS["main"]["host"]["port"]) 42 | # 43 | #def test_configuration_load_host_from_file(): 44 | # _ = tempfile.NamedTemporaryFile() 45 | # config_file = tempfile.NamedTemporaryFile(mode='w+', encoding='utf-8', delete=False) 46 | # config_file.file.write(SAMPLE_CONFIG) 47 | # config_file.close() 48 | # config = Configuration(config_file=config_file.name) 49 | # config.load() 50 | # os.remove(config_file.name) 51 | # _.close() 52 | # assert config.host == "{scheme}://{domain}:{port}".format(scheme=SAMPLE_HOST["scheme"], 53 | # domain=SAMPLE_HOST["domain"], 54 | # port=SAMPLE_HOST["port"]) 55 | # 56 | #def test_configuration_load_site_from_file(): 57 | # _ = tempfile.NamedTemporaryFile() 58 | # config_file = tempfile.NamedTemporaryFile(mode='w+', encoding='utf-8', delete=False) 59 | # config_file.file.write(SAMPLE_CONFIG) 60 | # config_file.close() 61 | # config = Configuration(config_file=config_file.name) 62 | # config.load() 63 | # os.remove(config_file.name) 64 | # _.close() 65 | # assert config.site == str(SAMPLE_HOST["domain"]) 66 | # 67 | #def test_configuration_auth_property_is_none_when_no_token_is_loaded(): 68 | # _ = tempfile.NamedTemporaryFile() 69 | # config = Configuration(config_file=_) 70 | # _.close() 71 | # assert config.auth_token is None 72 | # 73 | #def test_configuration_save_to_file(): 74 | # config_file = tempfile.NamedTemporaryFile(mode='w+', encoding='utf-8', delete=False) 75 | # config_file.file.write(SAMPLE_CONFIG) 76 | # config_file.close() 77 | # config = Configuration(config_file=config_file.name) 78 | # config.load() 79 | # config.save() 80 | # parser = ConfigParser() 81 | # parser.read(config_file.name, encoding="utf-8") 82 | # os.remove(config_file.name) 83 | # assert "host" in parser._sections 84 | # assert "site" in parser._sections 85 | # assert "auth" not in parser._sections 86 | # for k, v in SAMPLE_HOST.items(): 87 | # assert str(v) == parser._sections["host"][k] 88 | # assert str(SAMPLE_HOST["domain"]) == parser._sections["site"]["domain"] 89 | # 90 | #def test_auth_configuration_save_to_file(): 91 | # auth_config_file = tempfile.NamedTemporaryFile(mode='w+', encoding='utf-8', delete=False) 92 | # auth_config_file.file.write(SAMPLE_AUTH_CONFIG) 93 | # auth_config_file.close() 94 | # config = Configuration(config_file=auth_config_file.name) 95 | # config.load() 96 | # config.save() 97 | # parser = ConfigParser() 98 | # parser.read(auth_config_file.name, encoding="utf-8") 99 | # os.remove(auth_config_file.name) 100 | # assert "auth" in parser._sections 101 | # assert "host" not in parser._sections 102 | # assert "site" not in parser._sections 103 | # assert "keys" not in parser._sections 104 | # assert "colors" not in parser._sections 105 | # assert SAMPLE_AUTH["token"] == parser._sections["auth"]["token"] 106 | -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | from concurrent.futures import Future 2 | from unittest import mock 3 | 4 | from taiga_ncurses.config import settings 5 | from taiga_ncurses.core import TaigaCore, StateMachine 6 | from taiga_ncurses import controllers 7 | 8 | from . import factories 9 | 10 | 11 | def test_if_client_is_not_authenticated_the_login_view_is_shown_on_startup(): 12 | executor = factories.patched_executor() 13 | core = TaigaCore(executor, settings, authenticated=False) 14 | assert isinstance(core.controller, controllers.auth.LoginController) 15 | assert core.state_machine.state == StateMachine.LOGIN 16 | 17 | def test_if_client_is_authenticated_the_projects_view_is_shown_on_startup(): 18 | executor = factories.patched_executor() 19 | core = TaigaCore(executor, settings, authenticated=True, draw=False) 20 | assert isinstance(core.controller, controllers.projects.ProjectsController) 21 | assert core.state_machine.state == StateMachine.PROJECTS 22 | 23 | def test_transitioning_from_projects_to_project_detail_and_project_backlog(): 24 | projects = factories.projects() 25 | project = factories.project() 26 | project_f = Future() 27 | us = Future() 28 | stats = Future() 29 | executor = factories.patched_executor(project_detail=project_f, 30 | unassigned_user_stories=us, 31 | project_stats=stats,) 32 | core = TaigaCore(executor, settings, authenticated=True, draw=False) 33 | assert isinstance(core.controller, controllers.projects.ProjectsController) 34 | assert core.state_machine.state == StateMachine.PROJECTS 35 | core.state_machine.project_detail(project) 36 | project_f.set_result(project) 37 | us.set_result([]) 38 | stats.set_result(factories.project_stats()) 39 | assert isinstance(core.controller, controllers.projects.ProjectDetailController) 40 | assert core.state_machine.state == StateMachine.PROJECT_BACKLOG 41 | 42 | -------------------------------------------------------------------------------- /tests/test_executor.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | from concurrent.futures import Future 3 | 4 | from taiga_ncurses import executor 5 | 6 | 7 | # Auth 8 | def test_login_method_returns_a_future(): 9 | client = mock.Mock() 10 | e = executor.Executor(client) 11 | f = e.login("admin", "123123") 12 | assert isinstance(f, Future) 13 | 14 | # Project 15 | def test_projects_method_returns_a_future(): 16 | client = mock.Mock() 17 | e = executor.Executor(client) 18 | f = e.projects() 19 | assert isinstance(f, Future) 20 | 21 | def test_project_detail_method_returns_a_future(): 22 | client = mock.Mock() 23 | e = executor.Executor(client) 24 | f = e.project_detail({"id": 123123}) 25 | assert isinstance(f, Future) 26 | 27 | def test_project_stats_method_returns_a_future(): 28 | client = mock.Mock() 29 | e = executor.Executor(client) 30 | f = e.project_stats({"id": 123123}) 31 | assert isinstance(f, Future) 32 | 33 | def test_project_issues_stats_method_returns_a_future(): 34 | client = mock.Mock() 35 | e = executor.Executor(client) 36 | f = e.project_issues_stats({"id": 123123}) 37 | assert isinstance(f, Future) 38 | 39 | # Milestones 40 | def test_milestone_method_returns_a_future(): 41 | client = mock.Mock() 42 | e = executor.Executor(client) 43 | f = e.milestone({"id": 123123}, {"id": 123123}) 44 | assert isinstance(f, Future) 45 | 46 | def test_milestone_stats_method_returns_a_future(): 47 | client = mock.Mock() 48 | e = executor.Executor(client) 49 | f = e.milestone_stats({"id": 123123}, {"id": 123123}) 50 | assert isinstance(f, Future) 51 | 52 | # User Stories 53 | def test_create_user_story_method_returns_a_future(): 54 | client = mock.Mock() 55 | e = executor.Executor(client) 56 | f = e.create_user_story({"subject": "Foo Bar"}) 57 | assert isinstance(f, Future) 58 | 59 | def test_update_user_story_method_returns_a_future(): 60 | client = mock.Mock() 61 | e = executor.Executor(client) 62 | f = e.update_user_story({"id": 123123}, {"subject": "Bar Foo"}) 63 | assert isinstance(f, Future) 64 | 65 | def test_delete_user_story_method_returns_a_future(): 66 | client = mock.Mock() 67 | e = executor.Executor(client) 68 | f = e.delete_user_story({"id": 123123}) 69 | assert isinstance(f, Future) 70 | 71 | def test_create_user_stories_in_bulk_method_returns_a_future(): 72 | client = mock.Mock() 73 | e = executor.Executor(client) 74 | f = e.create_user_stories_in_bulk({"ulkStories": "A\nB\nC", "projectId": 1}) 75 | assert isinstance(f, Future) 76 | 77 | def test_update_user_stories_order_method_returns_a_future(): 78 | client = mock.Mock() 79 | e = executor.Executor(client) 80 | f = e.update_user_stories_order([{"id": 123123}, {"id": 456456}], {"id": 123123}) 81 | assert isinstance(f, Future) 82 | 83 | def test_unassigned_user_stories_method_returns_a_future(): 84 | client = mock.Mock() 85 | e = executor.Executor(client) 86 | f = e.unassigned_user_stories({"id": 123123}) 87 | assert isinstance(f, Future) 88 | 89 | def test_user_stories_method_returns_a_future(): 90 | client = mock.Mock() 91 | e = executor.Executor(client) 92 | f = e.user_stories({"id": 123123}, {"id": 123123}) 93 | 94 | # Task 95 | def test_tasks_method_returns_a_future(): 96 | client = mock.Mock() 97 | e = executor.Executor(client) 98 | f = e.tasks({"id": 123123}, {"id": 123123}) 99 | assert isinstance(f, Future) 100 | 101 | def test_create_task_method_returns_a_future(): 102 | client = mock.Mock() 103 | e = executor.Executor(client) 104 | f = e.create_task({"subject": "Foo Bar"}) 105 | assert isinstance(f, Future) 106 | 107 | def test_update_task_method_returns_a_future(): 108 | client = mock.Mock() 109 | e = executor.Executor(client) 110 | f = e.update_task({"id": 123123}, {"subject": "Bar Foo"}) 111 | assert isinstance(f, Future) 112 | 113 | def test_delete_task_method_returns_a_future(): 114 | client = mock.Mock() 115 | e = executor.Executor(client) 116 | f = e.delete_task({"id": 123123}) 117 | assert isinstance(f, Future) 118 | 119 | # Issues 120 | def test_issues_method_returns_a_future(): 121 | client = mock.Mock() 122 | e = executor.Executor(client) 123 | f = e.issues({"id": 123123}, ["status"], {"status": 1}) 124 | assert isinstance(f, Future) 125 | 126 | def test_create_issue_method_returns_a_future(): 127 | client = mock.Mock() 128 | e = executor.Executor(client) 129 | f = e.create_issue({"subject": "Foo Bar"}) 130 | assert isinstance(f, Future) 131 | 132 | def test_update_issue_method_returns_a_future(): 133 | client = mock.Mock() 134 | e = executor.Executor(client) 135 | f = e.update_issue({"id": 123123}, {"subject": "Bar Foo"}) 136 | assert isinstance(f, Future) 137 | 138 | def test_delete_issue_method_returns_a_future(): 139 | client = mock.Mock() 140 | e = executor.Executor(client) 141 | f = e.delete_issue({"id": 123123}) 142 | assert isinstance(f, Future) 143 | 144 | # Wiki 145 | def test_wiki_pages_method_returns_a_future(): 146 | client = mock.Mock() 147 | e = executor.Executor(client) 148 | f = e.wiki_pages({"id": 123123}) 149 | assert isinstance(f, Future) 150 | 151 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | from taiga_ncurses.ui import views 2 | 3 | 4 | def test_login_views_username_property_is_the_typed_username(): 5 | lv = views.auth.LoginView("username", "password") 6 | username = "Hulk Hogan" 7 | lv._username_editor.set_edit_text(username) 8 | assert lv.username == username 9 | 10 | def test_login_views_password_property_is_the_typed_password(): 11 | lv = views.auth.LoginView("username", "password") 12 | password = "1234567890" 13 | lv._password_editor.set_edit_text(password) 14 | assert lv.password == password 15 | --------------------------------------------------------------------------------