Skip to content

Commit 049d982

Browse files
authoredFeb 22, 2018
Merge pull request #167 from hasgeek/classview
Class-based views
2 parents ab2a911 + a93fccf commit 049d982

17 files changed

+1127
-36
lines changed
 

‎CHANGES.rst

+3
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
* New: ``coaster.auth`` module with a ``current_auth`` proxy that provides
1919
a standardised API for login managers to use
2020
* New: ``is_collection`` util for testing if an item is a collection data type
21+
* New: ``coaster.views.requires_permission`` decorator
22+
* New: ``coaster.views.classview`` provides a new take on organising views
23+
into a class
2124

2225

2326
0.6.0

‎coaster/app.py

+3
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from flask.json import tojson_filter as _tojson_filter
1818
from . import logger
1919
from .auth import current_auth
20+
from .views import current_view
2021

2122
__all__ = ['SandboxedFlask', 'init_app']
2223

@@ -103,6 +104,8 @@ def init_app(app, env=None):
103104
"""
104105
# Make current_auth available to app templates
105106
app.jinja_env.globals['current_auth'] = current_auth
107+
# Make the current view available to app templates
108+
app.jinja_env.globals['current_view'] = current_view
106109
# Disable Flask-SQLAlchemy events.
107110
# Apps that want it can turn it back on in their config
108111
app.config.setdefault('SQLALCHEMY_TRACK_MODIFICATIONS', False)

‎coaster/sqlalchemy/comparators.py

+13
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from __future__ import absolute_import
99
import uuid as uuid_
1010
from sqlalchemy.ext.hybrid import Comparator
11+
from flask import abort
1112
from flask_sqlalchemy import BaseQuery
1213
import six
1314
from ..utils import buid2uuid, suuid2uuid
@@ -38,6 +39,18 @@ def isempty(self):
3839
"""
3940
return not self.session.query(self.exists()).scalar()
4041

42+
def one_or_404(self):
43+
"""
44+
Extends :meth:`~sqlalchemy.orm.query.Query.one_or_none` to raise a 404
45+
if no result is found. This method offers a safety net over
46+
:meth:`~flask_sqlalchemy.BaseQuery.first_or_404` as it helps identify
47+
poorly specified queries that could have returned more than one result.
48+
"""
49+
result = self.one_or_none()
50+
if not result:
51+
abort(404)
52+
return result
53+
4154

4255
class SplitIndexComparator(Comparator):
4356
"""

‎coaster/sqlalchemy/mixins.py

+29-6
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ class MyModel(BaseMixin, db.Model):
3131
from sqlalchemy_utils.types import UUIDType
3232
from flask import url_for
3333
import six
34-
from ..utils import make_name, uuid2suuid, uuid2buid, buid2uuid, suuid2uuid
34+
from ..utils import make_name, uuid2suuid, uuid2buid, buid2uuid, suuid2uuid, InspectableSet
35+
from ..auth import current_auth
3536
from .immutable_annotation import immutable
3637
from .roles import RoleMixin, with_roles
3738
from .comparators import Query, SqlSplitIdComparator, SqlHexUuidComparator, SqlBuidComparator, SqlSuuidComparator
@@ -225,6 +226,15 @@ def permissions(self, user, inherited=None):
225226
else:
226227
return set()
227228

229+
@property
230+
def current_permissions(self):
231+
"""
232+
:class:`~coaster.utils.classes.InspectableSet` containing currently
233+
available permissions from this object, using
234+
:obj:`~coaster.auth.current_auth`.
235+
"""
236+
return InspectableSet(self.permissions(current_auth.actor))
237+
228238

229239
class UrlForMixin(object):
230240
"""
@@ -262,6 +272,9 @@ def url_for(self, action='view', **kwargs):
262272

263273
@classmethod
264274
def is_url_for(cls, _action, _endpoint=None, _external=None, **paramattrs):
275+
"""
276+
View decorator that registers the view as a :meth:`url_for` target.
277+
"""
265278
def decorator(f):
266279
if 'url_for_endpoints' not in cls.__dict__:
267280
cls.url_for_endpoints = {} # Stick it into the class with the first endpoint
@@ -536,7 +549,7 @@ def __init__(self, *args, **kw):
536549
self.make_name()
537550

538551
def __repr__(self):
539-
return '<%s %s "%s">' % (self.__class__.__name__, self.url_name, self.title)
552+
return '<%s %s "%s">' % (self.__class__.__name__, self.url_id_name, self.title)
540553

541554
def make_name(self):
542555
"""Autogenerates a :attr:`name` from the :attr:`title`"""
@@ -571,11 +584,19 @@ def url_name_suuid(self):
571584
Returns a URL name combining :attr:`name` and :attr:`suuid` in name-suuid syntax.
572585
To use this, the class must derive from :class:`UuidMixin`.
573586
"""
574-
return '%s-%s' % (self.name, self.suuid)
587+
if isinstance(self, UuidMixin):
588+
return '%s-%s' % (self.name, self.suuid)
589+
else:
590+
return '%s-%s' % (self.name, self.url_id)
575591

576592
@url_name_suuid.comparator
577593
def url_name_suuid(cls):
578-
return SqlSuuidComparator(cls.uuid, splitindex=-1)
594+
if issubclass(cls, UuidMixin):
595+
return SqlSuuidComparator(cls.uuid, splitindex=-1)
596+
elif cls.__uuid_primary_key__:
597+
return SqlHexUuidComparator(cls.id, splitindex=-1)
598+
else:
599+
return SqlSplitIdComparator(cls.id, splitindex=-1)
579600

580601

581602
class BaseScopedIdMixin(BaseMixin):
@@ -624,8 +645,10 @@ def permissions(self, user, inherited=None):
624645
"""
625646
if inherited is not None:
626647
return inherited | super(BaseScopedIdMixin, self).permissions(user)
627-
else:
648+
elif self.parent is not None and isinstance(self.parent, PermissionMixin):
628649
return self.parent.permissions(user) | super(BaseScopedIdMixin, self).permissions(user)
650+
else:
651+
return super(BaseScopedIdMixin, self).permissions(user)
629652

630653

631654
class BaseScopedIdNameMixin(BaseScopedIdMixin):
@@ -682,7 +705,7 @@ def __init__(self, *args, **kw):
682705
self.make_name()
683706

684707
def __repr__(self):
685-
return '<%s %s "%s" of %s>' % (self.__class__.__name__, self.url_name, self.title,
708+
return '<%s %s "%s" of %s>' % (self.__class__.__name__, self.url_id_name, self.title,
686709
repr(self.parent)[1:-1] if self.parent else None)
687710

688711
@classmethod

‎coaster/sqlalchemy/statemanager.py

+11-5
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,9 @@ def add_transition(self, statemanager, from_, to, if_=None, data=None):
456456
'if': if_, # Additional conditions that must ALL pass
457457
}
458458

459+
def __set_name__(self, owner, name): # pragma: no cover
460+
self.name = name
461+
459462
# Make the transition a non-data descriptor
460463
def __get__(self, obj, cls=None):
461464
if obj is None:
@@ -501,6 +504,9 @@ def is_available(self):
501504
"""
502505
return not self._state_invalid()
503506

507+
def __getattr__(self, name):
508+
return getattr(self.statetransition, name)
509+
504510
def __call__(self, *args, **kwargs):
505511
"""Call the transition"""
506512
# Validate that each of the state managers is in the correct state
@@ -827,17 +833,17 @@ def group(self, items, keep_empty=False):
827833
del groups[key]
828834
return groups
829835

830-
def __getattr__(self, attr):
836+
def __getattr__(self, name):
831837
"""
832838
Given the name of a state, returns:
833839
834-
1. If called on an instance, a boolean indicating if the state is active
840+
1. If called on an instance, a ManagedStateWrapper, which implements __bool__
835841
2. If called on a class, a query filter
836842
837843
Returns the default value or raises :exc:`AttributeError` on anything else.
838844
"""
839-
if hasattr(self.statemanager, attr):
840-
mstate = getattr(self.statemanager, attr)
845+
if hasattr(self.statemanager, name):
846+
mstate = getattr(self.statemanager, name)
841847
if isinstance(mstate, (ManagedState, ManagedStateGroup)):
842848
return mstate(self.obj, self.cls)
843-
raise AttributeError("Not a state: %s" % attr)
849+
raise AttributeError("Not a state: %s" % name)

‎coaster/utils/classes.py

+18-3
Original file line numberDiff line numberDiff line change
@@ -214,9 +214,10 @@ def value_for(cls, name):
214214

215215
class InspectableSet(Set):
216216
"""
217-
Given a set, mimics a dictionary where the items are keys and have a value
218-
of ``True``, and any other key has a value of ``False``. Also supports
219-
attribute access. Useful in templates to simplify membership inspection::
217+
Given a set, mimics a read-only dictionary where the items are keys and
218+
have a value of ``True``, and any other key has a value of ``False``. Also
219+
supports attribute access. Useful in templates to simplify membership
220+
inspection::
220221
221222
>>> myset = InspectableSet({'member', 'other'})
222223
>>> 'member' in myset
@@ -231,6 +232,20 @@ class InspectableSet(Set):
231232
True
232233
>>> myset['random']
233234
False
235+
>>> joinset = myset | {'added'}
236+
>>> isinstance(joinset, InspectableSet)
237+
True
238+
>>> joinset = joinset | InspectableSet({'inspectable'})
239+
>>> isinstance(joinset, InspectableSet)
240+
True
241+
>>> 'member' in joinset
242+
True
243+
>>> 'other' in joinset
244+
True
245+
>>> 'added' in joinset
246+
True
247+
>>> 'inspectable' in joinset
248+
True
234249
"""
235250
def __init__(self, members):
236251
if not isinstance(members, set):

‎coaster/views/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@
1010
from __future__ import absolute_import
1111
from .misc import * # NOQA
1212
from .decorators import * # NOQA
13+
from .classview import * # NOQA

‎coaster/views/classview.py

+519
Large diffs are not rendered by default.

‎coaster/views/decorators.py

+56-16
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,13 @@
1919
request, Response, url_for)
2020
from ..utils import is_collection
2121
from ..auth import current_auth, add_auth_attribute
22-
from .misc import jsonp as render_jsonp
22+
from .misc import jsonp
2323

2424
__all__ = [
2525
'RequestTypeError', 'RequestValueError',
2626
'requestargs', 'requestform', 'requestquery',
2727
'load_model', 'load_models',
28-
'render_with', 'cors',
28+
'render_with', 'cors', 'requires_permission',
2929
]
3030

3131

@@ -108,7 +108,7 @@ def datasource():
108108
return request.values if request else {}
109109

110110
@wraps(f)
111-
def decorated_function(**kw):
111+
def decorated_function(*args, **kw):
112112
values = datasource()
113113
for name, filt, is_list in namefilt:
114114
# Process name if
@@ -123,7 +123,7 @@ def decorated_function(**kw):
123123
except ValueError as e:
124124
raise RequestValueError(e)
125125
try:
126-
return f(**kw)
126+
return f(*args, **kw)
127127
except TypeError as e:
128128
raise RequestTypeError(e)
129129
return decorated_function
@@ -236,7 +236,7 @@ def show_page(folder, page):
236236
"""
237237
def inner(f):
238238
@wraps(f)
239-
def decorated_function(**kw):
239+
def decorated_function(*args, **kw):
240240
permissions = None
241241
permission_required = kwargs.get('permission')
242242
url_check_attributes = kwargs.get('urlcheck', [])
@@ -316,14 +316,13 @@ def decorated_function(**kw):
316316
if permission_required and not (permission_required & permissions):
317317
abort(403)
318318
if kwargs.get('kwargs'):
319-
return f(kwargs=kw, **result)
319+
return f(*args, kwargs=kw, **result)
320320
else:
321-
return f(**result)
321+
return f(*args, **result)
322322
return decorated_function
323323
return inner
324324

325325

326-
327326
def _best_mimetype_match(available_list, accept_mimetypes, default=None):
328327
for use_mimetype, quality in accept_mimetypes:
329328
for mimetype in available_list:
@@ -332,7 +331,21 @@ def _best_mimetype_match(available_list, accept_mimetypes, default=None):
332331
return default
333332

334333

335-
def render_with(template, json=False, jsonp=False):
334+
def dict_jsonify(param):
335+
"""Convert the parameter into a dictionary before calling jsonify, if it's not already one"""
336+
if not isinstance(param, dict):
337+
param = dict(param)
338+
return jsonify(param)
339+
340+
341+
def dict_jsonp(param):
342+
"""Convert the parameter into a dictionary before calling jsonp, if it's not already one"""
343+
if not isinstance(param, dict):
344+
param = dict(param)
345+
return jsonp(param)
346+
347+
348+
def render_with(template=None, json=False, jsonp=False):
336349
"""
337350
Decorator to render the wrapped function with the given template (or dictionary
338351
of mimetype keys to templates, where the template is a string name of a template
@@ -393,30 +406,29 @@ def myview():
393406
"""
394407
if jsonp:
395408
templates = {
396-
'application/json': render_jsonp,
397-
'text/json': render_jsonp,
398-
'text/x-json': render_jsonp,
409+
'application/json': dict_jsonp,
410+
'application/javascript': dict_jsonp,
399411
}
400412
elif json:
401413
templates = {
402-
'application/json': jsonify,
403-
'text/json': jsonify,
404-
'text/x-json': jsonify,
414+
'application/json': dict_jsonify,
405415
}
406416
else:
407417
templates = {}
408418
if isinstance(template, six.string_types):
409419
templates['text/html'] = template
410420
elif isinstance(template, dict):
411421
templates.update(template)
422+
elif template is None and (json or jsonp):
423+
pass
412424
else: # pragma: no cover
413425
raise ValueError("Expected string or dict for template")
414426

415427
default_mimetype = '*/*'
416428
if '*/*' not in templates:
417429
templates['*/*'] = six.text_type
418430
default_mimetype = 'text/plain'
419-
for mimetype in ('text/html', 'text/plain', 'application/json', 'text/json', 'text/x-json'):
431+
for mimetype in ('text/html', 'text/plain', 'application/json'):
420432
if mimetype in templates:
421433
templates['*/*'] = templates[mimetype]
422434
default_mimetype = mimetype # Remember which mimetype's handler is serving for */*
@@ -582,3 +594,31 @@ def wrapper(*args, **kwargs):
582594
return resp
583595
return wrapper
584596
return inner
597+
598+
599+
def requires_permission(permission):
600+
"""
601+
View decorator that requires a certain permission to be present in
602+
``current_auth.permissions`` before the view is allowed to proceed.
603+
Aborts with ``403 Forbidden`` if the permission is not present.
604+
605+
:param permission: Permission that is required. If an iterable is provided,
606+
any one permission must be available
607+
"""
608+
def inner(f):
609+
@wraps(f)
610+
def wrapper(*args, **kwargs):
611+
add_auth_attribute('login_required', True)
612+
if not hasattr(current_auth, 'permissions'):
613+
test = False
614+
elif is_collection(permission):
615+
test = bool(current_auth.permissions.intersection(permission))
616+
else:
617+
test = permission in current_auth.permissions
618+
if not test:
619+
abort(403)
620+
return f(*args, **kwargs)
621+
622+
wrapper.requires_permission = permission
623+
return wrapper
624+
return inner

‎docs/conf.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -248,5 +248,7 @@
248248
# Example configuration for intersphinx: refer to the Python standard library.
249249
intersphinx_mapping = {
250250
'https://docs.python.org/2/': None,
251-
'http://flask-sqlalchemy.pocoo.org/2.2/': None,
251+
'http://flask.pocoo.org/docs/': None,
252+
'http://docs.sqlalchemy.org/en/latest/': None,
253+
'http://flask-sqlalchemy.pocoo.org/2.3/': None,
252254
}

‎docs/views/classview.rst

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.. automodule:: coaster.views.classview
2+
:members:

‎docs/views/index.rst

+1
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@
55

66
misc
77
decorators
8+
classview

‎tests/test_models.py

+2-3
Original file line numberDiff line numberDiff line change
@@ -837,9 +837,8 @@ def test_uuid_url_id_name_suuid(self):
837837

838838
self.assertEqual(u1.url_id, '74d588574a7611e78c27c38403d0935c')
839839
self.assertEqual(u1.url_id_name, '74d588574a7611e78c27c38403d0935c-test')
840-
with self.assertRaises(AttributeError):
841-
# No UuidMixin == No suuid or url_name_suuid attributes
842-
self.assertEqual(u1.url_name_suuid, 'test-vVoaZTeXGiD4qrMtYNosnN')
840+
# No UuidMixin means no suuid, so fallback to hex UUID
841+
self.assertEqual(u1.url_name_suuid, 'test-74d588574a7611e78c27c38403d0935c')
843842
self.assertEqual(u2.url_id, '74d588574a7611e78c27c38403d0935c')
844843
self.assertEqual(u2.url_id_name, '74d588574a7611e78c27c38403d0935c-test')
845844
self.assertEqual(u2.url_name_suuid, 'test-vVoaZTeXGiD4qrMtYNosnN')

‎tests/test_views.py

+39-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
# -*- coding: utf-8 -*-
22

33
import unittest
4-
from werkzeug.exceptions import BadRequest
4+
from werkzeug.exceptions import BadRequest, Forbidden
55
from flask import Flask, session, json
66
from coaster.app import load_config_from_file
7-
from coaster.views import get_current_url, get_next_url, jsonp, requestargs, requestquery, requestform
7+
from coaster.auth import current_auth, add_auth_attribute
8+
from coaster.views import (get_current_url, get_next_url, jsonp, requestargs, requestquery, requestform,
9+
requires_permission)
810

911

1012
def index():
@@ -45,6 +47,18 @@ def requestcombo_test(query1, form1):
4547
return query1, form1
4648

4749

50+
@requires_permission('allow-this')
51+
def permission1():
52+
return 'allowed1'
53+
54+
55+
@requires_permission({'allow-this', 'allow-that'})
56+
def permission2():
57+
return 'allowed2'
58+
59+
60+
# --- Tests -------------------------------------------------------------------
61+
4862
class TestCoasterViews(unittest.TestCase):
4963
def setUp(self):
5064
self.app = Flask(__name__)
@@ -134,3 +148,26 @@ def test_requestargs(self):
134148

135149
# Calling without a request context works as well
136150
self.assertEqual(requestargs_test1(p1='1', p2=3, p3=[1, 2]), ('1', 3, [1, 2]))
151+
152+
def test_requires_permission(self):
153+
with self.app.test_request_context():
154+
with self.assertRaises(Forbidden):
155+
permission1()
156+
with self.assertRaises(Forbidden):
157+
permission2()
158+
159+
add_auth_attribute('permissions', set())
160+
161+
with self.assertRaises(Forbidden):
162+
permission1()
163+
with self.assertRaises(Forbidden):
164+
permission2()
165+
166+
current_auth.permissions.add('allow-that') # FIXME! Shouldn't this be a frozenset?
167+
with self.assertRaises(Forbidden):
168+
permission1()
169+
assert permission2() == 'allowed2'
170+
171+
current_auth.permissions.add('allow-this')
172+
assert permission1() == 'allowed1'
173+
assert permission2() == 'allowed2'

‎tests/test_views_classview.py

+427
Large diffs are not rendered by default.
File renamed without changes.
File renamed without changes.

0 commit comments

Comments
 (0)
Please sign in to comment.