Skip to content

Commit 5769a92

Browse files
rczajkamaxtepkeev
authored andcommitted
support special characters in wiki page titles
1 parent 137ea84 commit 5769a92

File tree

7 files changed

+78
-26
lines changed

7 files changed

+78
-26
lines changed

redminelib/managers/base.py

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
Defines base Redmine resource manager class and it's infrastructure.
33
"""
44

5-
from .. import utilities, resultsets, exceptions
5+
from .. import resultsets, exceptions
66

77

88
class ResourceManager(object):
@@ -149,16 +149,14 @@ def create(self, **fields):
149149
if not fields:
150150
raise exceptions.ResourceNoFieldsProvidedError
151151

152-
formatter = utilities.MemorizeFormatter()
153-
154152
try:
155-
url = self._construct_create_url(formatter.format(self.resource_class.query_create, **fields))
153+
url = self._construct_create_url(self.resource_class.query_create.format(**fields))
156154
except KeyError as e:
157155
raise exceptions.ValidationError('{0} field is required'.format(e))
158156

159-
self.params = formatter.used_kwargs
157+
self.params = self.resource_class.query_create.formatter.used_kwargs
160158
self.container = self.resource_class.container_create
161-
request = self._prepare_create_request(formatter.unused_kwargs)
159+
request = self._prepare_create_request(self.resource_class.query_create.formatter.unused_kwargs)
162160
response = self.redmine.engine.request(self.resource_class.http_method_create, url, data=request)
163161
resource = self._process_create_response(request, response)
164162
self.url = self.redmine.url + self.resource_class.query_one.format(resource.internal_id, **fields)
@@ -203,21 +201,19 @@ def update(self, resource_id, **fields):
203201
if not fields:
204202
raise exceptions.ResourceNoFieldsProvidedError
205203

206-
formatter = utilities.MemorizeFormatter()
207-
208204
try:
209-
query_update = formatter.format(self.resource_class.query_update, resource_id, **fields)
205+
query_update = self.resource_class.query_update.format(resource_id, **fields)
210206
except KeyError as e:
211207
param = e.args[0]
212208

213209
if param in self.params:
214210
fields[param] = self.params[param]
215-
query_update = formatter.format(self.resource_class.query_update, resource_id, **fields)
211+
query_update = self.resource_class.query_update.format(resource_id, **fields)
216212
else:
217213
raise exceptions.ValidationError('{0} argument is required'.format(e))
218214

219215
url = self._construct_update_url(query_update)
220-
request = self._prepare_update_request(formatter.unused_kwargs)
216+
request = self._prepare_update_request(self.resource_class.query_update.formatter.unused_kwargs)
221217
response = self.redmine.engine.request(self.resource_class.http_method_update, url, data=request)
222218
return self._process_update_response(request, response)
223219

redminelib/resources/base.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ class Registrar(type):
1919
which name starts with Base are considered base classes and not added to the registry.
2020
"""
2121
def __new__(mcs, name, bases, attrs):
22+
mcs.update_query_strings(attrs)
23+
2224
cls = super(Registrar, mcs).__new__(mcs, name, bases, attrs)
2325

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

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

58+
@staticmethod
59+
def update_query_strings(attrs):
60+
"""
61+
Updates all `query_*` string attributes to use ResourceQueryFormatter by default.
62+
"""
63+
for k, v in attrs.items():
64+
if k.startswith('query_') and v is not None:
65+
attrs[k] = utilities.ResourceQueryStr(v)
66+
return attrs
67+
5668
@staticmethod
5769
def update_cls_attr(cls, name, value):
5870
"""

redminelib/resources/standard.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -201,12 +201,13 @@ class Enumeration(BaseResource):
201201
redmine_version = '2.2'
202202
container_filter = '{resource}'
203203
query_filter = '/enumerations/{resource}.json'
204+
query_url = '/enumerations/{0}/edit'
204205

205206
_resource_set_map = {'custom_fields': 'CustomField'}
206207

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

211212

212213
class Attachment(BaseResource):
@@ -489,58 +490,63 @@ class News(BaseResource):
489490
query_all_export = '/news.{format}'
490491
query_all = '/news.json'
491492
query_filter = '/news.json'
493+
query_url = '/news/{0}'
492494
search_hints = ['news']
493495

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

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

501503

502504
class IssueStatus(BaseResource):
503505
redmine_version = '1.3'
504506
container_all = 'issue_statuses'
505507
query_all = '/issue_statuses.json'
508+
query_url = '/issue_statuses/{0}/edit'
506509

507510
_relations = ['issues']
508511
_relations_name = 'status'
509512
_resource_set_map = {'issues': 'Issue'}
510513

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

515518

516519
class Tracker(BaseResource):
517520
redmine_version = '1.3'
518521
container_all = 'trackers'
519522
query_all = '/trackers.json'
523+
query_url = '/trackers/{0}/edit'
520524

521525
_relations = ['issues']
522526
_resource_set_map = {'issues': 'Issue'}
523527

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

528532

529533
class Query(BaseResource):
530534
redmine_version = '1.3'
531535
container_all = 'queries'
532536
query_all = '/queries.json'
537+
query_url = '/projects/{0}/issues?query_id={1}'
533538

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

539544

540545
class CustomField(BaseResource):
541546
redmine_version = '2.4'
542547
container_all = 'custom_fields'
543548
query_all = '/custom_fields.json'
549+
query_url = '/custom_fields/{0}/edit'
544550

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

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

567573
@property
568574
def url(self):
569-
return '{0}/custom_fields/{1}/edit'.format(self.manager.redmine.url, self.internal_id)
575+
return self.manager.redmine.url + self.query_url.format(self.internal_id)

redminelib/resultsets.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import functools
77
import itertools
88

9-
from . import lookups, utilities, exceptions
9+
from . import lookups, exceptions
1010

1111

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

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

60-
url = self.manager.redmine.url + formatter.format(
61-
self.manager.resource_class.query_all_export, format=fmt, **self.manager.params)
62-
63-
params = formatter.unused_kwargs
61+
params = self.manager.resource_class.query_all_export.formatter.unused_kwargs
6462

6563
if columns is not None:
6664
if columns == 'all':

redminelib/utilities.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@
77
import string
88
import functools
99

10+
try:
11+
# Python 3
12+
from urllib.parse import quote
13+
except ImportError:
14+
# Python 2
15+
from urllib import quote
16+
1017

1118
def fix_unicode(cls):
1219
"""
@@ -65,9 +72,9 @@ def merge_dicts(a, b):
6572
return result
6673

6774

68-
class MemorizeFormatter(string.Formatter):
75+
class ResourceQueryFormatter(string.Formatter):
6976
"""
70-
Memorizes all arguments, used during string formatting.
77+
Quotes query and memorizes all arguments, used during string formatting.
7178
"""
7279
def __init__(self):
7380
self.used_kwargs = {}
@@ -79,3 +86,13 @@ def check_unused_args(self, used_args, args, kwargs):
7986
self.used_kwargs[item] = kwargs.pop(item)
8087

8188
self.unused_kwargs = kwargs
89+
90+
def format_field(self, value, format_spec):
91+
return quote(super(ResourceQueryFormatter, self).format_field(value, format_spec).encode('utf-8'))
92+
93+
94+
class ResourceQueryStr(str):
95+
formatter = ResourceQueryFormatter()
96+
97+
def format(self, *args, **kwargs):
98+
return self.formatter.format(self, *args, **kwargs)

tests/responses/standard.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
},
2525
'wiki_page': {
2626
'get': {'wiki_page': {'title': 'Foo', 'version': 1}},
27+
'get_special': {'wiki_page': {'title': 'Foo%Bar', 'version': 1}},
2728
'filter': {'wiki_pages': [{'title': 'Foo', 'version': 1}, {'title': 'Bar', 'version': 2}]},
2829
},
2930
'project_membership': {

tests/test_resources_standard.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -702,6 +702,17 @@ def test_wiki_page_get(self):
702702
wiki_page = self.redmine.wiki_page.get('Foo', project_id=1)
703703
self.assertEqual(wiki_page.title, 'Foo')
704704

705+
def test_wiki_page_get_special(self):
706+
"""Test getting a wiki page with special char in title."""
707+
self.response.json.return_value = responses['wiki_page']['get_special']
708+
wiki_page = self.redmine.wiki_page.get('Foo%Bar', project_id=1)
709+
self.assertEqual(
710+
self.patch_requests.call_args[0][1],
711+
'{0}/projects/1/wiki/Foo%25Bar.json'.format(self.url)
712+
)
713+
self.assertEqual(wiki_page.title, 'Foo%Bar')
714+
self.assertEqual(wiki_page.url, 'http://foo.bar/projects/1/wiki/Foo%25Bar')
715+
705716
def test_wiki_page_filter(self):
706717
self.response.json.return_value = responses['wiki_page']['filter']
707718
wiki_pages = self.redmine.wiki_page.filter(project_id=1)
@@ -714,6 +725,17 @@ def test_wiki_page_create(self):
714725
wiki_page = self.redmine.wiki_page.create(project_id='foo', title='Foo')
715726
self.assertEqual(wiki_page.title, 'Foo')
716727

728+
def test_wiki_page_create_special(self):
729+
"""Test creating a wiki page with special char in title."""
730+
self.response.status_code = 201
731+
self.response.json.return_value = responses['wiki_page']['get_special']
732+
wiki_page = self.redmine.wiki_page.create(project_id='foo', title='Foo%Bar')
733+
self.assertEqual(
734+
self.patch_requests.call_args[0][1],
735+
'{0}/projects/foo/wiki/Foo%25Bar.json'.format(self.url)
736+
)
737+
self.assertEqual(wiki_page.title, 'Foo%Bar')
738+
717739
def test_wiki_page_delete(self):
718740
self.response.json.return_value = responses['wiki_page']['get']
719741
wiki_page = self.redmine.wiki_page.get('Foo', project_id=1)

0 commit comments

Comments
 (0)