Projet

Général

Profil

0001-api-newsletters-retreival-endpoint-10794.patch

Voir les différences:

Subject: [PATCH 1/2] api: newsletters retreival endpoint (#10794)

 corbo/api_urls.py           |  23 +++
 corbo/api_views.py          |  31 ++++
 corbo/channels.py           |   1 +
 corbo/urls.py               |   4 +-
 corbo/utils/__init__.py     |  22 +++
 corbo/utils/jsonresponse.py | 384 ++++++++++++++++++++++++++++++++++++++++++++
 jenkins.sh                  |  13 ++
 tests/conftest.py           |   9 ++
 tests/test_api.py           |  45 ++++++
 tox.ini                     |  22 +++
 10 files changed, 553 insertions(+), 1 deletion(-)
 create mode 100644 corbo/api_urls.py
 create mode 100644 corbo/api_views.py
 create mode 100644 corbo/utils/__init__.py
 create mode 100644 corbo/utils/jsonresponse.py
 create mode 100755 jenkins.sh
 create mode 100644 tests/conftest.py
 create mode 100644 tests/test_api.py
 create mode 100644 tox.ini
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
-