Repository: nephila/python-taiga
Pull request #7 (2015-05-18 06:39:18)

Feature/customattributes


Patch:
diff --git a/README.md b/README.md
index 57ef715..5d75b0a 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,10 @@
 python-taiga
 ==============
 [![Build Status][travis-image]][travis-url] [![Coveralls Status][coveralls-image]][coveralls-url]
-[![Latest Version](https://img.shields.io/pypi/v/python-taiga.svg)](https://pypi.python.org/pypi/python-taiga/)
-[![Supported Python versions](https://img.shields.io/badge/python-2.7%2C%202.8%2C%203.3%2C%203.4-blue.svg)](https://pypi.python.org/pypi/python-taiga/)
-[![License](https://img.shields.io/github/license/nephila/python-taiga.svg)](https://pypi.python.org/pypi/python-taiga/)
-[![Downloads](https://img.shields.io/pypi/dm/python-taiga.svg)](https://pypi.python.org/pypi/python-taiga/)
+[![Latest Version](https://pypip.in/version/python-taiga/badge.svg?style=flat)](https://pypi.python.org/pypi/python-taiga/)
+[![Supported Python versions](https://pypip.in/py_versions/python-taiga/badge.svg?style=flat)](https://pypi.python.org/pypi/python-taiga/)
+[![License](https://pypip.in/license/python-taiga/badge.svg?style=flat)](https://pypi.python.org/pypi/python-taiga/)
+[![Downloads](https://pypip.in/download/python-taiga/badge.svg?style=flat)](https://pypi.python.org/pypi/python-taiga/)
 
 A module for using the [Taiga REST API](http://taigaio.github.io/taiga-doc/dist/api.html "Taiga REST API documentation").
 
@@ -96,6 +96,15 @@ newissue = new_project.add_issue(
 )
 ```
 
+### Create a custom attribute
+
+```python
+new_project.add_issue_attribute(
+    'Device', description='(iPad, iPod, iPhone, Desktop, etc.)'
+)
+newissue.set_attribute('1', 'Desktop')
+```
+
 ### List elements
 
 ```python
diff --git a/demo.py b/demo.py
index a39684b..0745ef6 100644
--- a/demo.py
+++ b/demo.py
@@ -66,3 +66,7 @@ users = api.users.list()
 print (users)
 
 print (api.search(projects.get(name='TEST PROJECT 3').id, 'New').user_stories[0].subject)
+
+print new_project.add_issue_attribute(
+    'Device', description='(iPad, iPod, iPhone, Desktop, etc.)'
+)
diff --git a/taiga/client.py b/taiga/client.py
index 3b1f2c5..64e1b32 100644
--- a/taiga/client.py
+++ b/taiga/client.py
@@ -11,6 +11,9 @@ from .models import UserStoryStatuses
 from .models import Severities
 from .models import Priorities
 from .models import IssueStatuses
+from .models import IssueAttributes
+from .models import TaskAttributes
+from .models import UserStoryAttributes
 from .models import TaskStatuses
 from .models import WikiPages
 from .models import WikiLinks
@@ -47,6 +50,9 @@ class TaigaAPI:
         self.severities = Severities(self.raw_request)
         self.points = Points(self.raw_request)
         self.issue_statuses = IssueStatuses(self.raw_request)
+        self.issue_attributes = IssueAttributes(self.raw_request)
+        self.task_attributes = TaskAttributes(self.raw_request)
+        self.user_story_attributes = UserStoryAttributes(self.raw_request)
         self.task_statuses = TaskStatuses(self.raw_request)
         self.priorities = Priorities(self.raw_request)
         self.user_story_statuses = UserStoryStatuses(self.raw_request)
diff --git a/taiga/models/__init__.py b/taiga/models/__init__.py
index 87b036d..23ec176 100644
--- a/taiga/models/__init__.py
+++ b/taiga/models/__init__.py
@@ -20,6 +20,12 @@ from .models import Priority
 from .models import Priorities
 from .models import IssueStatus
 from .models import IssueStatuses
+from .models import IssueAttribute
+from .models import IssueAttributes
+from .models import TaskAttribute
+from .models import TaskAttributes
+from .models import UserStoryAttribute
+from .models import UserStoryAttributes
 from .models import TaskStatus
 from .models import TaskStatuses
 from .models import WikiPage
@@ -34,5 +40,7 @@ __all__ = [
     'Point', 'Points', 'UserStoryStatus', 'UserStoryStatuses',
     'Severity', 'Severities', 'Priority', 'Priorities', 'IssueStatus',
     'IssueStatuses', 'TaskStatus', 'TaskStatuses', 'WikiPage', 'WikiPages',
-    'WikiLink', 'WikiLinks'
+    'WikiLink', 'WikiLinks', 'IssueAttribute', 'IssueAttributes',
+    'TaskAttribute', 'TaskAttributes', 'UserStoryAttribute',
+    'UserStoryAttributes'
 ]
diff --git a/taiga/models/models.py b/taiga/models/models.py
index 09ff0f7..d50842d 100644
--- a/taiga/models/models.py
+++ b/taiga/models/models.py
@@ -1,5 +1,55 @@
 import datetime
 from .base import InstanceResource, ListResource
+from taiga.exceptions import TaigaException
+
+
+class CustomAttributeResource(InstanceResource):
+
+    def set_attribute(self, id, value, version=1):
+        attributes = self.get_attributes()
+        formatted_id = '{0}'.format(id)
+        if formatted_id in attributes['attributes_values']:
+            attributes['attributes_values'][formatted_id] = value
+        else:
+            raise TaigaException(
+                'Attribute with id {0} doesn\'t exist'.format(formatted_id)
+            )
+        response = self.requester.patch(
+            '/{endpoint}/custom-attributes-values/{id}',
+            endpoint=self.endpoint, id=self.id,
+            payload={
+                'attributes_values': attributes['attributes_values'],
+                'version': version
+            }
+        )
+        return response.json()
+
+    def get_attributes(self):
+        response = self.requester.get(
+            '/{endpoint}/custom-attributes-values/{id}',
+            endpoint=self.endpoint, id=self.id,
+        )
+        return response.json()
+
+
+class CustomAttribute(InstanceResource):
+
+    repr_attribute = 'name'
+
+    allowed_params = [
+        'name', 'description', 'order', 'project'
+    ]
+
+
+class CustomAttributes(ListResource):
+
+    def create(self, project, name, **attrs):
+        attrs.update(
+            {
+                'project': project, 'name': name
+            }
+        )
+        return self._new_resource(payload=attrs)
 
 
 class User(InstanceResource):
@@ -69,7 +119,7 @@ class UserStoryAttachments(Attachments):
     instance = UserStoryAttachment
 
 
-class UserStory(InstanceResource):
+class UserStory(CustomAttributeResource):
 
     endpoint = 'userstories'
 
@@ -212,7 +262,7 @@ class TaskAttachments(Attachments):
     instance = TaskAttachment
 
 
-class Task(InstanceResource):
+class Task(CustomAttributeResource):
 
     endpoint = 'tasks'
 
@@ -288,7 +338,7 @@ class IssueAttachments(Attachments):
     instance = IssueAttachment
 
 
-class Issue(InstanceResource):
+class Issue(CustomAttributeResource):
 
     endpoint = 'issues'
 
@@ -337,6 +387,36 @@ class Issues(ListResource):
         return self._new_resource(payload=attrs)
 
 
+class IssueAttribute(CustomAttribute):
+
+    endpoint = 'issue-custom-attributes'
+
+
+class IssueAttributes(CustomAttributes):
+
+    instance = IssueAttribute
+
+
+class TaskAttribute(CustomAttribute):
+
+    endpoint = 'task-custom-attributes'
+
+
+class TaskAttributes(CustomAttributes):
+
+    instance = TaskAttribute
+
+
+class UserStoryAttribute(CustomAttribute):
+
+    endpoint = 'userstory-custom-attributes'
+
+
+class UserStoryAttributes(CustomAttributes):
+
+    instance = UserStoryAttribute
+
+
 class Severity(InstanceResource):
 
     endpoint = 'severities'
@@ -480,6 +560,30 @@ class Project(InstanceResource):
     def list_wikilinks(self):
         return WikiLinks(self.requester).list(project=self.id)
 
+    def add_issue_attribute(self, name, **attrs):
+        return IssueAttributes(self.requester).create(
+            self.id, name, **attrs
+        )
+
+    def list_issue_attributes(self):
+        return IssueAttributes(self.requester).list(project=self.id)
+
+    def add_task_attribute(self, name, **attrs):
+        return TaskAttributes(self.requester).create(
+            self.id, name, **attrs
+        )
+
+    def list_task_attributes(self):
+        return TaskAttributes(self.requester).list(project=self.id)
+
+    def add_user_story_attribute(self, name, **attrs):
+        return UserStoryAttributes(self.requester).create(
+            self.id, name, **attrs
+        )
+
+    def list_user_story_attributes(self):
+        return UserStoryAttributes(self.requester).list(project=self.id)
+
 
 class Projects(ListResource):
 
diff --git a/taiga/requestmaker.py b/taiga/requestmaker.py
index c8a87e3..8edd991 100644
--- a/taiga/requestmaker.py
+++ b/taiga/requestmaker.py
@@ -136,3 +136,28 @@ class RequestMaker(object):
                 full_url, result.status_code,
                 result.text, 'PUT'
             )
+
+    def patch(self, uri, payload=None, query={}, **parameters):
+        try:
+            full_url = self.urljoin(
+                self.host, self.api_path,
+                uri.format(**parameters)
+            )
+            result = requests.patch(
+                full_url,
+                headers=self.headers(),
+                data=json.dumps(payload),
+                params=query
+            )
+        except RequestException:
+            raise exceptions.TaigaRestException(
+                full_url, 400,
+                'Network error!', 'PATCH'
+            )
+        if not self.is_bad_response(result):
+            return result
+        else:
+            raise exceptions.TaigaRestException(
+                full_url, result.status_code,
+                result.text, 'PATCH'
+            )


Test patch:
diff --git a/tests/resources/issue_customattr_details_success.json b/tests/resources/issue_customattr_details_success.json
new file mode 100644
index 0000000..5057759
--- /dev/null
+++ b/tests/resources/issue_customattr_details_success.json
@@ -0,0 +1,7 @@
+{
+    "id": 1,
+    "name": "Duration",
+    "description": "Duration in minutes",
+    "order": 1,
+    "project": 1
+}
\ No newline at end of file
diff --git a/tests/resources/issue_customattr_success.json b/tests/resources/issue_customattr_success.json
new file mode 100644
index 0000000..815237c
--- /dev/null
+++ b/tests/resources/issue_customattr_success.json
@@ -0,0 +1,7 @@
+{
+    "issue": 1,
+    "attributes_values": {
+        "1": "240 minutes"
+    },
+    "version": 2
+}
\ No newline at end of file
diff --git a/tests/test_custom_attributes.py b/tests/test_custom_attributes.py
new file mode 100644
index 0000000..daec33e
--- /dev/null
+++ b/tests/test_custom_attributes.py
@@ -0,0 +1,67 @@
+from taiga.requestmaker import RequestMaker
+from taiga.models import Issue, Issues, IssueAttributes, IssueAttribute
+from taiga.exceptions import TaigaException
+import unittest
+from mock import patch
+from .tools import create_mock_json
+from .tools import MockResponse
+import six
+
+if six.PY2:
+    import_open = '__builtin__.open'
+else:
+    import_open = 'builtins.open'
+
+class TestCustomAttributes(unittest.TestCase):
+
+    @patch('taiga.requestmaker.RequestMaker.get')
+    @patch('taiga.requestmaker.RequestMaker.patch')
+    def test_edit_issue_custom_attribute(self, mock_requestmaker_patch, mock_requestmaker_get):
+        mock_requestmaker_get.return_value = MockResponse(200,
+            create_mock_json('tests/resources/issue_customattr_success.json'))
+        mock_requestmaker_patch.return_value = MockResponse(200,
+            create_mock_json('tests/resources/issue_customattr_success.json'))
+        rm = RequestMaker('/api/v1', 'fakehost', 'faketoken')
+        issue = Issue(rm, id=1, project=1)
+        new_attribute = issue.set_attribute(1, 13)
+        self.assertTrue('attributes_values' in new_attribute)
+        mock_requestmaker_patch.assert_called_with(
+            '/{endpoint}/custom-attributes-values/{id}',
+            endpoint=Issue.endpoint, id=issue.id,
+            payload={
+                'attributes_values': {u'1': 13},
+                'version': 1
+            }
+        )
+
+    @patch('taiga.requestmaker.RequestMaker.get')
+    @patch('taiga.requestmaker.RequestMaker.patch')
+    def test_edit_wrong_issue_custom_attribute(self, mock_requestmaker_patch, mock_requestmaker_get):
+        mock_requestmaker_get.return_value = MockResponse(200,
+            create_mock_json('tests/resources/issue_customattr_success.json'))
+        mock_requestmaker_patch.return_value = MockResponse(200,
+            create_mock_json('tests/resources/issue_customattr_success.json'))
+        rm = RequestMaker('/api/v1', 'fakehost', 'faketoken')
+        issue = Issue(rm, id=1, project=1)
+        self.assertRaises(TaigaException, issue.set_attribute, 2, 13)
+
+    @patch('taiga.requestmaker.RequestMaker.get')
+    def test_get_issue_custom_attributes(self, mock_requestmaker_get):
+        mock_requestmaker_get.return_value = MockResponse(200,
+            create_mock_json('tests/resources/issue_customattr_success.json'))
+        rm = RequestMaker('/api/v1', 'fakehost', 'faketoken')
+        issue = Issue(rm, id=1, project=1)
+        my_attributes = issue.get_attributes()
+        self.assertTrue('attributes_values' in my_attributes)
+        mock_requestmaker_get.assert_called_with(
+            '/{endpoint}/custom-attributes-values/{id}',
+            endpoint=Issue.endpoint, id=issue.id,
+        )
+
+    @patch('taiga.requestmaker.RequestMaker.post')
+    def test_issue_attribute_creation(self, mock_requestmaker_post):
+        mock_requestmaker_post.return_value = MockResponse(200,
+            create_mock_json('tests/resources/issue_details_success.json'))
+        rm = RequestMaker('/api/v1', 'fakehost', 'faketoken')
+        issue_attribute = IssueAttributes(rm).create(1, 'new attribute')
+        self.assertTrue(isinstance(issue_attribute, IssueAttribute))
diff --git a/tests/test_projects.py b/tests/test_projects.py
index e4cb1da..2e9d642 100644
--- a/tests/test_projects.py
+++ b/tests/test_projects.py
@@ -241,3 +241,45 @@ class TestProjects(unittest.TestCase):
         project = Project(rm, id=1)
         project.list_wikilinks()
         mock_list_wikilinks.assert_called_with(project=1)
+
+    @patch('taiga.models.IssueAttributes.create')
+    def test_add_issue_attribute(self, mock_new_issue_attr):
+        rm = RequestMaker('/api/v1', 'fakehost', 'faketoken')
+        project = Project(rm, id=1)
+        project.add_issue_attribute('New Attribute')
+        mock_new_issue_attr.assert_called_with(1, 'New Attribute')
+
+    @patch('taiga.models.IssueAttributes.list')
+    def test_list_issue_attributes(self, mock_list_issues_attr):
+        rm = RequestMaker('/api/v1', 'fakehost', 'faketoken')
+        project = Project(rm, id=1)
+        project.list_issue_attributes()
+        mock_list_issues_attr.assert_called_with(project=1)
+
+    @patch('taiga.models.TaskAttributes.create')
+    def test_add_task_attribute(self, mock_new_task_attr):
+        rm = RequestMaker('/api/v1', 'fakehost', 'faketoken')
+        project = Project(rm, id=1)
+        project.add_task_attribute('New Attribute')
+        mock_new_task_attr.assert_called_with(1, 'New Attribute')
+
+    @patch('taiga.models.TaskAttributes.list')
+    def test_list_task_attributes(self, mock_list_issues_attr):
+        rm = RequestMaker('/api/v1', 'fakehost', 'faketoken')
+        project = Project(rm, id=1)
+        project.list_task_attributes()
+        mock_list_issues_attr.assert_called_with(project=1)
+
+    @patch('taiga.models.UserStoryAttributes.create')
+    def test_add_user_story_attribute(self, mock_new_us_attr):
+        rm = RequestMaker('/api/v1', 'fakehost', 'faketoken')
+        project = Project(rm, id=1)
+        project.add_user_story_attribute('New Attribute')
+        mock_new_us_attr.assert_called_with(1, 'New Attribute')
+
+    @patch('taiga.models.UserStoryAttributes.list')
+    def test_list_user_story_attributes(self, mock_list_us_attr):
+        rm = RequestMaker('/api/v1', 'fakehost', 'faketoken')
+        project = Project(rm, id=1)
+        project.list_user_story_attributes()
+        mock_list_us_attr.assert_called_with(project=1)
diff --git a/tests/test_requestmaker.py b/tests/test_requestmaker.py
index 11568f6..e97617e 100644
--- a/tests/test_requestmaker.py
+++ b/tests/test_requestmaker.py
@@ -43,6 +43,13 @@ class TestRequestMaker(unittest.TestCase):
         rm.put('/nowhere')
         self.assertTrue(requests_put.called)
 
+    @patch('taiga.requestmaker.requests.patch')
+    def test_call_requests_patch(self, requests_patch):
+        rm = RequestMaker(api_path='/', host='host', token='f4k3')
+        requests_patch.return_value = MockResponse(200, '')
+        rm.patch('/nowhere')
+        self.assertTrue(requests_patch.called)
+
     @patch('taiga.requestmaker.requests.delete')
     def test_call_requests_delete(self, requests_delete):
         rm = RequestMaker(api_path='/', host='host', token='f4k3')
@@ -68,6 +75,12 @@ class TestRequestMaker(unittest.TestCase):
         requests_put.return_value = MockResponse(400, '')
         self.assertRaises(taiga.exceptions.TaigaRestException, rm.put, '/nowhere')
 
+    @patch('taiga.requestmaker.requests.patch')
+    def test_call_requests_patch_raise_exception_on_bad_response(self, requests_patch):
+        rm = RequestMaker(api_path='/', host='host', token='f4k3')
+        requests_patch.return_value = MockResponse(400, '')
+        self.assertRaises(taiga.exceptions.TaigaRestException, rm.patch, '/nowhere')
+
     @patch('taiga.requestmaker.requests.delete')
     def test_call_requests_delete_raise_exception_on_bad_response(self, requests_delete):
         rm = RequestMaker(api_path='/', host='host', token='f4k3')
@@ -92,6 +105,12 @@ class TestRequestMaker(unittest.TestCase):
         requests_put.side_effect = requests.RequestException()
         self.assertRaises(taiga.exceptions.TaigaRestException, rm.put, '/nowhere')
 
+    @patch('taiga.requestmaker.requests.patch')
+    def test_call_requests_patch_raise_exception_on_requests_error(self, requests_patch):
+        rm = RequestMaker(api_path='/', host='host', token='f4k3')
+        requests_patch.side_effect = requests.RequestException()
+        self.assertRaises(taiga.exceptions.TaigaRestException, rm.patch, '/nowhere')
+
     @patch('taiga.requestmaker.requests.delete')
     def test_call_requests_delete_raise_exception_on_requests_error(self, requests_delete):
         rm = RequestMaker(api_path='/', host='host', token='f4k3')