Skip to content

Commit

Permalink
support special characters in wiki page titles
Browse files Browse the repository at this point in the history
  • Loading branch information
rczajka authored and maxtepkeev committed Apr 25, 2019
1 parent 137ea84 commit 5769a92
Show file tree
Hide file tree
Showing 7 changed files with 78 additions and 26 deletions.
18 changes: 7 additions & 11 deletions redminelib/managers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
Defines base Redmine resource manager class and it's infrastructure.
"""

from .. import utilities, resultsets, exceptions
from .. import resultsets, exceptions


class ResourceManager(object):
Expand Down Expand Up @@ -149,16 +149,14 @@ def create(self, **fields):
if not fields:
raise exceptions.ResourceNoFieldsProvidedError

formatter = utilities.MemorizeFormatter()

try:
url = self._construct_create_url(formatter.format(self.resource_class.query_create, **fields))
url = self._construct_create_url(self.resource_class.query_create.format(**fields))
except KeyError as e:
raise exceptions.ValidationError('{0} field is required'.format(e))

self.params = formatter.used_kwargs
self.params = self.resource_class.query_create.formatter.used_kwargs
self.container = self.resource_class.container_create
request = self._prepare_create_request(formatter.unused_kwargs)
request = self._prepare_create_request(self.resource_class.query_create.formatter.unused_kwargs)
response = self.redmine.engine.request(self.resource_class.http_method_create, url, data=request)
resource = self._process_create_response(request, response)
self.url = self.redmine.url + self.resource_class.query_one.format(resource.internal_id, **fields)
Expand Down Expand Up @@ -203,21 +201,19 @@ def update(self, resource_id, **fields):
if not fields:
raise exceptions.ResourceNoFieldsProvidedError

formatter = utilities.MemorizeFormatter()

try:
query_update = formatter.format(self.resource_class.query_update, resource_id, **fields)
query_update = self.resource_class.query_update.format(resource_id, **fields)
except KeyError as e:
param = e.args[0]

if param in self.params:
fields[param] = self.params[param]
query_update = formatter.format(self.resource_class.query_update, resource_id, **fields)
query_update = self.resource_class.query_update.format(resource_id, **fields)
else:
raise exceptions.ValidationError('{0} argument is required'.format(e))

url = self._construct_update_url(query_update)
request = self._prepare_update_request(formatter.unused_kwargs)
request = self._prepare_update_request(self.resource_class.query_update.formatter.unused_kwargs)
response = self.redmine.engine.request(self.resource_class.http_method_update, url, data=request)
return self._process_update_response(request, response)

Expand Down
12 changes: 12 additions & 0 deletions redminelib/resources/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ class Registrar(type):
which name starts with Base are considered base classes and not added to the registry.
"""
def __new__(mcs, name, bases, attrs):
mcs.update_query_strings(attrs)

cls = super(Registrar, mcs).__new__(mcs, name, bases, attrs)

if name.startswith('Base'): # base classes shouldn't be added to the registry
Expand Down Expand Up @@ -53,6 +55,16 @@ def __new__(mcs, name, bases, attrs):

return registry[name].setdefault('class', cls)

@staticmethod
def update_query_strings(attrs):
"""
Updates all `query_*` string attributes to use ResourceQueryFormatter by default.
"""
for k, v in attrs.items():
if k.startswith('query_') and v is not None:
attrs[k] = utilities.ResourceQueryStr(v)
return attrs

@staticmethod
def update_cls_attr(cls, name, value):
"""
Expand Down
20 changes: 13 additions & 7 deletions redminelib/resources/standard.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,12 +201,13 @@ class Enumeration(BaseResource):
redmine_version = '2.2'
container_filter = '{resource}'
query_filter = '/enumerations/{resource}.json'
query_url = '/enumerations/{0}/edit'

_resource_set_map = {'custom_fields': 'CustomField'}

@property
def url(self):
return '{0}/enumerations/{1}/edit'.format(self.manager.redmine.url, self.internal_id)
return self.manager.redmine.url + self.query_url.format(self.internal_id)


class Attachment(BaseResource):
Expand Down Expand Up @@ -489,58 +490,63 @@ class News(BaseResource):
query_all_export = '/news.{format}'
query_all = '/news.json'
query_filter = '/news.json'
query_url = '/news/{0}'
search_hints = ['news']

_repr = [['id', 'title']]
_resource_map = {'project': 'Project', 'author': 'User'}

@property
def url(self):
return '{0}/news/{1}'.format(self.manager.redmine.url, self.internal_id)
return self.manager.redmine.url + self.query_url.format(self.internal_id)


class IssueStatus(BaseResource):
redmine_version = '1.3'
container_all = 'issue_statuses'
query_all = '/issue_statuses.json'
query_url = '/issue_statuses/{0}/edit'

_relations = ['issues']
_relations_name = 'status'
_resource_set_map = {'issues': 'Issue'}

@property
def url(self):
return '{0}/issue_statuses/{1}/edit'.format(self.manager.redmine.url, self.internal_id)
return self.manager.redmine.url + self.query_url.format(self.internal_id)


class Tracker(BaseResource):
redmine_version = '1.3'
container_all = 'trackers'
query_all = '/trackers.json'
query_url = '/trackers/{0}/edit'

_relations = ['issues']
_resource_set_map = {'issues': 'Issue'}

@property
def url(self):
return '{0}/trackers/{1}/edit'.format(self.manager.redmine.url, self.internal_id)
return self.manager.redmine.url + self.query_url.format(self.internal_id)


class Query(BaseResource):
redmine_version = '1.3'
container_all = 'queries'
query_all = '/queries.json'
query_url = '/projects/{0}/issues?query_id={1}'

@property
def url(self):
return '{0}/projects/{1}/issues?query_id={2}'.format(
self.manager.redmine.url, self._decoded_attrs.get('project_id', 0), self.internal_id)
return self.manager.redmine.url + self.query_url.format(
self._decoded_attrs.get('project_id', 0), self.internal_id)


class CustomField(BaseResource):
redmine_version = '2.4'
container_all = 'custom_fields'
query_all = '/custom_fields.json'
query_url = '/custom_fields/{0}/edit'

_resource_set_map = {'trackers': 'Tracker', 'roles': 'Role'}

Expand All @@ -566,4 +572,4 @@ def encode(cls, attr, value, manager):

@property
def url(self):
return '{0}/custom_fields/{1}/edit'.format(self.manager.redmine.url, self.internal_id)
return self.manager.redmine.url + self.query_url.format(self.internal_id)
10 changes: 4 additions & 6 deletions redminelib/resultsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import functools
import itertools

from . import lookups, utilities, exceptions
from . import lookups, exceptions


class BaseResourceSet(object):
Expand Down Expand Up @@ -55,12 +55,10 @@ def export(self, fmt, savepath=None, filename=None, columns=None):
if self.manager.resource_class.query_all_export is None:
raise exceptions.ExportNotSupported

formatter = utilities.MemorizeFormatter()
url = self.manager.redmine.url + self.manager.resource_class.query_all_export.format(
format=fmt, **self.manager.params)

url = self.manager.redmine.url + formatter.format(
self.manager.resource_class.query_all_export, format=fmt, **self.manager.params)

params = formatter.unused_kwargs
params = self.manager.resource_class.query_all_export.formatter.unused_kwargs

if columns is not None:
if columns == 'all':
Expand Down
21 changes: 19 additions & 2 deletions redminelib/utilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@
import string
import functools

try:
# Python 3
from urllib.parse import quote
except ImportError:
# Python 2
from urllib import quote


def fix_unicode(cls):
"""
Expand Down Expand Up @@ -65,9 +72,9 @@ def merge_dicts(a, b):
return result


class MemorizeFormatter(string.Formatter):
class ResourceQueryFormatter(string.Formatter):
"""
Memorizes all arguments, used during string formatting.
Quotes query and memorizes all arguments, used during string formatting.
"""
def __init__(self):
self.used_kwargs = {}
Expand All @@ -79,3 +86,13 @@ def check_unused_args(self, used_args, args, kwargs):
self.used_kwargs[item] = kwargs.pop(item)

self.unused_kwargs = kwargs

def format_field(self, value, format_spec):
return quote(super(ResourceQueryFormatter, self).format_field(value, format_spec).encode('utf-8'))


class ResourceQueryStr(str):
formatter = ResourceQueryFormatter()

def format(self, *args, **kwargs):
return self.formatter.format(self, *args, **kwargs)
1 change: 1 addition & 0 deletions tests/responses/standard.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
},
'wiki_page': {
'get': {'wiki_page': {'title': 'Foo', 'version': 1}},
'get_special': {'wiki_page': {'title': 'Foo%Bar', 'version': 1}},
'filter': {'wiki_pages': [{'title': 'Foo', 'version': 1}, {'title': 'Bar', 'version': 2}]},
},
'project_membership': {
Expand Down
22 changes: 22 additions & 0 deletions tests/test_resources_standard.py
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,17 @@ def test_wiki_page_get(self):
wiki_page = self.redmine.wiki_page.get('Foo', project_id=1)
self.assertEqual(wiki_page.title, 'Foo')

def test_wiki_page_get_special(self):
"""Test getting a wiki page with special char in title."""
self.response.json.return_value = responses['wiki_page']['get_special']
wiki_page = self.redmine.wiki_page.get('Foo%Bar', project_id=1)
self.assertEqual(
self.patch_requests.call_args[0][1],
'{0}/projects/1/wiki/Foo%25Bar.json'.format(self.url)
)
self.assertEqual(wiki_page.title, 'Foo%Bar')
self.assertEqual(wiki_page.url, 'http://foo.bar/projects/1/wiki/Foo%25Bar')

def test_wiki_page_filter(self):
self.response.json.return_value = responses['wiki_page']['filter']
wiki_pages = self.redmine.wiki_page.filter(project_id=1)
Expand All @@ -714,6 +725,17 @@ def test_wiki_page_create(self):
wiki_page = self.redmine.wiki_page.create(project_id='foo', title='Foo')
self.assertEqual(wiki_page.title, 'Foo')

def test_wiki_page_create_special(self):
"""Test creating a wiki page with special char in title."""
self.response.status_code = 201
self.response.json.return_value = responses['wiki_page']['get_special']
wiki_page = self.redmine.wiki_page.create(project_id='foo', title='Foo%Bar')
self.assertEqual(
self.patch_requests.call_args[0][1],
'{0}/projects/foo/wiki/Foo%25Bar.json'.format(self.url)
)
self.assertEqual(wiki_page.title, 'Foo%Bar')

def test_wiki_page_delete(self):
self.response.json.return_value = responses['wiki_page']['get']
wiki_page = self.redmine.wiki_page.get('Foo', project_id=1)
Expand Down

0 comments on commit 5769a92

Please sign in to comment.