0001-api-newsletters-retreival-endpoint-10794.patch
corbo/api_urls.py | ||
---|---|---|
1 |
# corbo - Announces Manager |
|
2 |
# Copyright (C) 2016 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
from django.conf.urls import patterns, include, url |
|
18 | ||
19 |
from .api_views import NewslettersView |
|
20 | ||
21 |
urlpatterns = patterns('', |
|
22 |
url(r'^newsletters/', NewslettersView.as_view(), name='newsletters'), |
|
23 |
) |
corbo/api_views.py | ||
---|---|---|
1 |
# corbo - Announces Manager |
|
2 |
# Copyright (C) 2016 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
from django.views.generic.base import View |
|
18 | ||
19 |
from .models import Category, Subscription |
|
20 |
from .utils import to_json, get_channels |
|
21 | ||
22 |
class NewslettersView(View): |
|
23 | ||
24 |
@to_json('api') |
|
25 |
def get(self, request): |
|
26 |
newsletters = [] |
|
27 |
for c in Category.objects.all(): |
|
28 |
newsletter = {'id': str(c.pk), 'text': c.name, |
|
29 |
'transports': get_channels()} |
|
30 |
newsletters.append(newsletter) |
|
31 |
return newsletters |
corbo/channels.py | ||
---|---|---|
9 | 9 |
for identifier, display_name in channel.get_choices(): |
10 | 10 |
yield (identifier, display_name) |
11 | 11 | |
12 | ||
12 | 13 |
class HomepageChannel(object): |
13 | 14 |
identifier = 'homepage' |
14 | 15 |
corbo/urls.py | ||
---|---|---|
8 | 8 |
from .views import homepage, atom |
9 | 9 | |
10 | 10 |
from manage_urls import urlpatterns as manage_urls |
11 |
from api_urls import urlpatterns as api_urls |
|
11 | 12 | |
12 | 13 |
urlpatterns = patterns('', |
13 | 14 |
url(r'^$', homepage, name='home'), |
... | ... | |
15 | 16 |
url(r'^manage/', decorated_includes(manager_required, |
16 | 17 |
include(manage_urls))), |
17 | 18 |
url(r'^ckeditor/', include('ckeditor.urls')), |
18 |
url(r'^admin/', include(admin.site.urls)) |
|
19 |
url(r'^admin/', include(admin.site.urls)), |
|
20 |
url(r'^api/', include(api_urls)) |
|
19 | 21 |
) |
20 | 22 | |
21 | 23 |
if 'mellon' in settings.INSTALLED_APPS: |
corbo/utils/__init__.py | ||
---|---|---|
1 |
# corbo - Announces Manager |
|
2 |
# Copyright (C) 2016 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
from .jsonresponse import to_json |
|
18 | ||
19 |
from ..channels import get_channel_choices |
|
20 | ||
21 |
def get_channels(): |
|
22 |
return [{'id': c_id, 'text': unicode(c_name)} for c_id, c_name in get_channel_choices()] |
corbo/utils/jsonresponse.py | ||
---|---|---|
1 |
# This module is a modified copy of code of Yasha's Borevich library |
|
2 |
# django-jsonresponse (https://github.com/jjay/django-jsonresponse) distributed |
|
3 |
# under BSD license |
|
4 | ||
5 |
import json |
|
6 |
import functools |
|
7 |
import logging |
|
8 |
from collections import Iterable |
|
9 | ||
10 |
from django.http import HttpResponse |
|
11 |
from django.conf import settings |
|
12 |
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied |
|
13 |
from django.core.serializers.json import DjangoJSONEncoder |
|
14 | ||
15 |
DEFAULT_DEBUG = getattr(settings, 'JSONRESPONSE_DEFAULT_DEBUG', False) |
|
16 |
CALLBACK_NAME = getattr(settings, 'JSONRESPONSE_CALLBACK_NAME', 'callback') |
|
17 | ||
18 | ||
19 |
class to_json(object): |
|
20 |
""" |
|
21 |
Wrap view functions to render python native and custom |
|
22 |
objects to json |
|
23 | ||
24 |
>>> from django.test.client import RequestFactory |
|
25 |
>>> requests = RequestFactory() |
|
26 | ||
27 |
Simple wrap returning data into json |
|
28 | ||
29 |
>>> @to_json('plain') |
|
30 |
... def hello(request): |
|
31 |
... return dict(hello='world') |
|
32 | ||
33 |
>>> resp = hello(requests.get('/hello/')) |
|
34 |
>>> print resp.status_code |
|
35 |
200 |
|
36 |
>>> print resp.content |
|
37 |
{"hello": "world"} |
|
38 | ||
39 |
Result can be wraped in some api manier |
|
40 | ||
41 |
>>> @to_json('api') |
|
42 |
... def goodbye(request): |
|
43 |
... return dict(good='bye') |
|
44 |
>>> resp = goodbye(requests.get('/goodbye', {'debug': 1})) |
|
45 |
>>> print resp.status_code |
|
46 |
200 |
|
47 |
>>> print resp.content |
|
48 |
{ |
|
49 |
"data": { |
|
50 |
"good": "bye" |
|
51 |
}, |
|
52 |
"err": 0 |
|
53 |
} |
|
54 | ||
55 |
Automaticaly error handling |
|
56 | ||
57 |
>>> @to_json('api') |
|
58 |
... def error(request): |
|
59 |
... raise Exception('Wooot!??') |
|
60 | ||
61 |
>>> resp = error(requests.get('/error', {'debug': 1})) |
|
62 |
>>> print resp.status_code |
|
63 |
500 |
|
64 |
>>> print resp.content # doctest: +NORMALIZE_WHITESPACE |
|
65 |
{ |
|
66 |
"err_class": "Exception", |
|
67 |
"err_desc": "Wooot!??", |
|
68 |
"data": null, |
|
69 |
"err": 1 |
|
70 |
} |
|
71 | ||
72 |
>>> from django.core.exceptions import ObjectDoesNotExist |
|
73 |
>>> @to_json('api') |
|
74 |
... def error_404(request): |
|
75 |
... raise ObjectDoesNotExist('Not found') |
|
76 | ||
77 |
>>> resp = error_404(requests.get('/error', {'debug': 1})) |
|
78 |
>>> print resp.status_code |
|
79 |
404 |
|
80 |
>>> print resp.content # doctest: +NORMALIZE_WHITESPACE |
|
81 |
{ |
|
82 |
"err_class": "django.core.exceptions.ObjectDoesNotExist", |
|
83 |
"err_desc": "Not found", |
|
84 |
"data": null, |
|
85 |
"err": 1 |
|
86 |
} |
|
87 | ||
88 | ||
89 |
You can serialize not only pure python data types. |
|
90 |
Implement `serialize` method on toplevel object or |
|
91 |
each element of toplevel array. |
|
92 | ||
93 |
>>> class User(object): |
|
94 |
... def __init__(self, name, age): |
|
95 |
... self.name = name |
|
96 |
... self.age = age |
|
97 |
... |
|
98 |
... def serialize(self, request): |
|
99 |
... if request.GET.get('with_age', False): |
|
100 |
... return dict(name=self.name, age=self.age) |
|
101 |
... else: |
|
102 |
... return dict(name=self.name) |
|
103 | ||
104 |
>>> @to_json('objects') |
|
105 |
... def users(request): |
|
106 |
... return [User('Bob', 10), User('Anna', 12)] |
|
107 | ||
108 |
>>> resp = users(requests.get('users', { 'debug': 1 })) |
|
109 |
>>> print resp.status_code |
|
110 |
200 |
|
111 |
>>> print resp.content # doctest: +NORMALIZE_WHITESPACE |
|
112 |
{ |
|
113 |
"data": [ |
|
114 |
{ |
|
115 |
"name": "Bob" |
|
116 |
}, |
|
117 |
{ |
|
118 |
"name": "Anna" |
|
119 |
} |
|
120 |
], |
|
121 |
"err": 0 |
|
122 |
} |
|
123 | ||
124 |
You can pass extra args for serialization: |
|
125 | ||
126 |
>>> resp = users(requests.get('users', |
|
127 |
... { 'debug':1, 'with_age':1 })) |
|
128 |
>>> print resp.status_code |
|
129 |
200 |
|
130 |
>>> print resp.content # doctest: +NORMALIZE_WHITESPACE |
|
131 |
{ |
|
132 |
"data": [ |
|
133 |
{ |
|
134 |
"age": 10, |
|
135 |
"name": "Bob" |
|
136 |
}, |
|
137 |
{ |
|
138 |
"age": 12, |
|
139 |
"name": "Anna" |
|
140 |
} |
|
141 |
], |
|
142 |
"err": 0 |
|
143 |
} |
|
144 | ||
145 |
It is easy to use jsonp, just pass format=jsonp |
|
146 | ||
147 |
>>> resp = users(requests.get('users', |
|
148 |
... { 'debug':1, 'format': 'jsonp' })) |
|
149 |
>>> print resp.status_code |
|
150 |
200 |
|
151 |
>>> print resp.content # doctest: +NORMALIZE_WHITESPACE |
|
152 |
callback({ |
|
153 |
"data": [ |
|
154 |
{ |
|
155 |
"name": "Bob" |
|
156 |
}, |
|
157 |
{ |
|
158 |
"name": "Anna" |
|
159 |
} |
|
160 |
], |
|
161 |
"err": 0 |
|
162 |
}); |
|
163 | ||
164 |
You can override the name of callback method using |
|
165 |
JSONRESPONSE_CALLBACK_NAME option or query arg callback=another_callback |
|
166 | ||
167 |
>>> resp = users(requests.get('users', |
|
168 |
... { 'debug':1, 'format': 'jsonp', 'callback': 'my_callback' })) |
|
169 |
>>> print resp.status_code |
|
170 |
200 |
|
171 |
>>> print resp.content # doctest: +NORMALIZE_WHITESPACE |
|
172 |
my_callback({ |
|
173 |
"data": [ |
|
174 |
{ |
|
175 |
"name": "Bob" |
|
176 |
}, |
|
177 |
{ |
|
178 |
"name": "Anna" |
|
179 |
} |
|
180 |
], |
|
181 |
"err": 0 |
|
182 |
}); |
|
183 | ||
184 |
You can pass raise=1 to raise exceptions in debug purposes |
|
185 |
instead of passing info to json response |
|
186 | ||
187 |
>>> @to_json('api') |
|
188 |
... def error(request): |
|
189 |
... raise Exception('Wooot!??') |
|
190 | ||
191 |
>>> resp = error(requests.get('/error', |
|
192 |
... {'debug': 1, 'raise': 1})) |
|
193 |
Traceback (most recent call last): |
|
194 |
Exception: Wooot!?? |
|
195 | ||
196 |
You can wraps both methods and functions |
|
197 | ||
198 |
>>> class View(object): |
|
199 |
... @to_json('plain') |
|
200 |
... def render(self, request): |
|
201 |
... return dict(data='ok') |
|
202 |
... @to_json('api') |
|
203 |
... def render_api(self, request): |
|
204 |
... return dict(data='ok') |
|
205 | ||
206 | ||
207 |
>>> view = View() |
|
208 |
>>> resp = view.render(requests.get('/render')) |
|
209 |
>>> print resp.status_code |
|
210 |
200 |
|
211 |
>>> print resp.content # doctest: +NORMALIZE_WHITESPACE |
|
212 |
{"data": "ok"} |
|
213 | ||
214 |
Try it one more |
|
215 | ||
216 |
>>> resp = view.render(requests.get('/render')) |
|
217 |
>>> print resp.status_code |
|
218 |
200 |
|
219 |
>>> print resp.content # doctest: +NORMALIZE_WHITESPACE |
|
220 |
{"data": "ok"} |
|
221 | ||
222 |
Try it one more with api |
|
223 | ||
224 |
>>> resp = view.render_api(requests.get('/render')) |
|
225 |
>>> print resp.status_code |
|
226 |
200 |
|
227 |
>>> print resp.content # doctest: +NORMALIZE_WHITESPACE |
|
228 |
{"data": {"data": "ok"}, "err": 0} |
|
229 | ||
230 | ||
231 |
You can pass custom kwargs to json.dumps, |
|
232 |
just give them to constructor |
|
233 | ||
234 |
>>> @to_json('plain', separators=(', ', ': ')) |
|
235 |
... def custom_kwargs(request): |
|
236 |
... return ['a', { 'b': 1 }] |
|
237 |
>>> resp = custom_kwargs(requests.get('/render')) |
|
238 |
>>> print resp.status_code |
|
239 |
200 |
|
240 |
>>> print resp.content |
|
241 |
["a", {"b": 1}] |
|
242 |
""" |
|
243 |
def __init__(self, serializer_type, error_code=500, **kwargs): |
|
244 |
""" |
|
245 |
serializer_types: |
|
246 |
* api - serialize buildin objects (dict, list, etc) in strict api |
|
247 |
* objects - serialize list of region in strict api |
|
248 |
* plain - just serialize result of function, do not wrap response and do not handle exceptions |
|
249 |
""" |
|
250 |
self.serializer_type = serializer_type |
|
251 |
self.method = None |
|
252 |
self.error_code=error_code |
|
253 |
self.kwargs = kwargs |
|
254 |
if 'cls' not in self.kwargs: |
|
255 |
self.kwargs['cls'] = DjangoJSONEncoder |
|
256 | ||
257 |
def __call__(self, f): |
|
258 |
@functools.wraps(f) |
|
259 |
def wrapper(*args, **kwargs): |
|
260 |
if self.method: |
|
261 |
return self.method(f, *args, **kwargs) |
|
262 | ||
263 |
if not args: |
|
264 |
if self.serializer_type == 'plain': |
|
265 |
self.method = self.plain_func |
|
266 |
else: |
|
267 |
self.method = self.api_func |
|
268 | ||
269 |
if getattr(getattr(args[0], f.__name__, None), "im_self", False): |
|
270 |
if self.serializer_type == 'plain': |
|
271 |
self.method = self.plain_method |
|
272 |
else: |
|
273 |
self.method = self.api_method |
|
274 |
else: |
|
275 |
if self.serializer_type == 'plain': |
|
276 |
self.method = self.plain_func |
|
277 |
else: |
|
278 |
self.method = self.api_func |
|
279 | ||
280 |
return self.method(f, *args, **kwargs) |
|
281 | ||
282 |
return wrapper |
|
283 | ||
284 |
def obj_to_response(self, req, obj): |
|
285 |
if self.serializer_type == 'objects': |
|
286 |
if isinstance(obj, Iterable): |
|
287 |
obj = [o.serialize(req) if obj else None for o in obj] |
|
288 |
elif obj: |
|
289 |
obj = obj.serialize(req) |
|
290 |
else: |
|
291 |
obj = None |
|
292 | ||
293 |
return { "err": 0, "data": obj } |
|
294 | ||
295 |
def err_to_response(self, err): |
|
296 |
if hasattr(err, "__module__"): |
|
297 |
err_module = err.__module__ + "." |
|
298 |
else: |
|
299 |
err_module = "" |
|
300 | ||
301 |
if hasattr(err, "owner"): |
|
302 |
err_module += err.owner.__name__ + "." |
|
303 | ||
304 |
err_class = err_module + err.__class__.__name__ |
|
305 | ||
306 |
err_desc = str(err) |
|
307 | ||
308 |
return { |
|
309 |
"err": 1, |
|
310 |
"err_class": err_class, |
|
311 |
"err_desc": err_desc, |
|
312 |
"data": None |
|
313 |
} |
|
314 | ||
315 |
def render_data(self, req, data, status=200): |
|
316 |
debug = DEFAULT_DEBUG |
|
317 |
debug = debug or req.GET.get('debug', 'false').lower() in ('true', 't', '1', 'on') |
|
318 |
debug = debug or req.GET.get('decode', '0').lower() in ('true', 't', '1', 'on') |
|
319 |
format = req.GET.get('format', 'json') |
|
320 |
jsonp_cb = req.GET.get('callback', CALLBACK_NAME) |
|
321 |
content_type = "application/json" |
|
322 | ||
323 |
kwargs = dict(self.kwargs) |
|
324 |
if debug: |
|
325 |
kwargs["indent"] = 4 |
|
326 |
kwargs["ensure_ascii"] = False |
|
327 |
kwargs["encoding"] = "utf8" |
|
328 | ||
329 |
plain = json.dumps(data, **kwargs) |
|
330 |
if format == 'jsonp': |
|
331 |
plain = "%s(%s);" % (jsonp_cb, plain) |
|
332 |
content_type = "application/javascript" |
|
333 | ||
334 |
return HttpResponse(plain, content_type="%s; charset=UTF-8" % content_type, status=status) |
|
335 | ||
336 |
def api_func(self, f, *args, **kwargs): |
|
337 |
return self.api(f, args[0], *args, **kwargs) |
|
338 | ||
339 |
def api_method(self, f, *args, **kwargs): |
|
340 |
return self.api(f, args[1], *args, **kwargs) |
|
341 | ||
342 |
def api(self, f, req, *args, **kwargs): |
|
343 |
logger = logging.getLogger('passerelle.jsonresponse') |
|
344 |
try: |
|
345 |
resp = f(*args, **kwargs) |
|
346 |
if isinstance(resp, HttpResponse): |
|
347 |
return resp |
|
348 | ||
349 |
data = self.obj_to_response(req, resp) |
|
350 |
status = 200 |
|
351 |
except Exception as e: |
|
352 |
extras = {'method': req.method} |
|
353 |
if req.method == 'POST': |
|
354 |
extras.update({'body': req.body}) |
|
355 |
logger.exception("Error occurred while processing request", extra=extras) |
|
356 |
if int(req.GET.get('raise', 0)): |
|
357 |
raise |
|
358 | ||
359 |
data = self.err_to_response(e) |
|
360 |
if getattr(e, 'err_code', None): |
|
361 |
data['err'] = e.err_code |
|
362 |
if getattr(e, 'http_status', None): |
|
363 |
status = e.http_status |
|
364 |
elif isinstance(e, ObjectDoesNotExist): |
|
365 |
status = 404 |
|
366 |
elif isinstance(e, PermissionDenied): |
|
367 |
status = 403 |
|
368 |
else: |
|
369 |
status = self.error_code |
|
370 |
return self.render_data(req, data, status) |
|
371 | ||
372 |
def plain_method(self, f, *args, **kwargs): |
|
373 |
data = f(*args, **kwargs) |
|
374 |
if isinstance(data, HttpResponse): |
|
375 |
return data |
|
376 | ||
377 |
return self.render_data(args[1], data) |
|
378 | ||
379 |
def plain_func(self, f, *args, **kwargs): |
|
380 |
data = f(*args, **kwargs) |
|
381 |
if isinstance(data, HttpResponse): |
|
382 |
return data |
|
383 | ||
384 |
return self.render_data(args[0], data) |
jenkins.sh | ||
---|---|---|
1 |
#!/bin/sh |
|
2 | ||
3 |
set -e |
|
4 | ||
5 |
rm -f coverage.xml |
|
6 |
rm -f test_results.xml |
|
7 | ||
8 |
pip install --upgrade tox |
|
9 |
pip install --upgrade pylint pylint-django |
|
10 |
tox -r |
|
11 |
test -f pylint.out && cp pylint.out pylint.out.prev |
|
12 |
(pylint -f parseable --rcfile /var/lib/jenkins/pylint.django.rc corbo/ | tee pylint.out) || /bin/true |
|
13 |
test -f pylint.out.prev && (diff pylint.out.prev pylint.out | grep '^[><]' | grep .py) || /bin/true |
tests/conftest.py | ||
---|---|---|
1 |
import pytest |
|
2 |
import django_webtest |
|
3 | ||
4 |
@pytest.fixture |
|
5 |
def app(request): |
|
6 |
wtm = django_webtest.WebTestMixin() |
|
7 |
wtm._patch_settings() |
|
8 |
request.addfinalizer(wtm._unpatch_settings) |
|
9 |
return django_webtest.DjangoTestApp() |
tests/test_api.py | ||
---|---|---|
1 |
import pytest |
|
2 |
import json |
|
3 | ||
4 | ||
5 |
from django.core.urlresolvers import reverse |
|
6 | ||
7 |
from corbo.models import Category, Announce, Broadcast |
|
8 |
from corbo.utils import get_channels |
|
9 | ||
10 |
pytestmark = pytest.mark.django_db |
|
11 | ||
12 |
CATEGORIES = ('Alerts', 'News') |
|
13 | ||
14 | ||
15 |
@pytest.fixture |
|
16 |
def categories(): |
|
17 |
categories = [] |
|
18 |
for category in CATEGORIES: |
|
19 |
c, created = Category.objects.get_or_create(name=category) |
|
20 |
categories.append(c) |
|
21 |
return categories |
|
22 | ||
23 |
@pytest.fixture |
|
24 |
def announces(): |
|
25 |
announces = [] |
|
26 |
for category in Category.objects.all(): |
|
27 |
a = Announce.objects.create(category=category, title='By email') |
|
28 |
Broadcast.objects.create(announce=a, channel='email') |
|
29 |
announces.append(a) |
|
30 |
a = Announce.objects.create(category=category, title='On homepage') |
|
31 |
Broadcast.objects.create(announce=a, channel='homepage') |
|
32 |
announces.append(a) |
|
33 |
return announces |
|
34 | ||
35 | ||
36 |
def test_get_newsletters(app, categories, announces): |
|
37 |
resp = app.get(reverse('newsletters'), status=200) |
|
38 |
data = resp.json |
|
39 |
assert data['data'] |
|
40 |
for category in data['data']: |
|
41 |
assert 'id' in category |
|
42 |
assert 'text' in category |
|
43 |
assert category['text'] in CATEGORIES |
|
44 |
assert 'transports' in category |
|
45 |
assert category['transports'] == get_channels() |
tox.ini | ||
---|---|---|
1 |
[tox] |
|
2 |
envlist = coverage-{django17,django18} |
|
3 | ||
4 |
[testenv] |
|
5 |
usedevelop = |
|
6 |
coverage: True |
|
7 |
setenv = |
|
8 |
DJANGO_SETTINGS_MODULE=corbo.settings |
|
9 |
coverage: COVERAGE=--junitxml=test_results.xml --cov-report xml --cov=corbo/ --cov-config .coveragerc |
|
10 |
deps = |
|
11 |
django17: django>1.7,<1.8 |
|
12 |
django18: django>=1.8,<1.9 |
|
13 |
pytest-cov |
|
14 |
pytest-django |
|
15 |
pytest |
|
16 |
pytest-capturelog |
|
17 |
django-webtest |
|
18 |
django-ckeditor<4.5.3 |
|
19 |
pylint==1.4.0 |
|
20 |
astroid==1.3.2 |
|
21 |
commands = |
|
22 |
py.test {env:COVERAGE:} {posargs:tests/} |
|
0 |
- |