Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Class-based views #167

Merged
merged 27 commits into from
Feb 22, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
cdddf63
Rename view tests to be easier to locate
jace Feb 13, 2018
822cf72
render_with tweaks
jace Feb 13, 2018
25ffb04
Introducing class-based views
jace Feb 13, 2018
f027e94
Fix tests for Python 2.7
jace Feb 14, 2018
635a4c5
b'str' works in Py2. Who knew?
jace Feb 14, 2018
01ccd87
Allow subclasses to replace view handlers while keeping routes
jace Feb 14, 2018
53eadc2
Docstrings for ClassView tests
jace Feb 14, 2018
87dc295
Add ClassView.get_view and support added routes on existing handlers
jace Feb 14, 2018
3f1041f
Replace ClassView.get_view with add_route_for
jace Feb 14, 2018
e86e491
Documentation and misc cleanup in ClassView
jace Feb 14, 2018
c99971b
Add a view decorator wrapper to make reroute work
jace Feb 14, 2018
3346323
Add test for direct call to a view
jace Feb 14, 2018
796f0dd
First pass at ModelView
jace Feb 14, 2018
7a17962
Support relationship traversal in InstanceLoader
jace Feb 15, 2018
7aa7d14
Merge branch 'master' into classview
jace Feb 20, 2018
33195a7
Added requires_permission decorator for current_auth
jace Feb 20, 2018
290749f
Fix PermissionMixin check
jace Feb 20, 2018
5e8b450
current_view proxy (also available in Jinja2 env)
jace Feb 20, 2018
d364ea9
Add support for permissions in InstanceLoader
jace Feb 20, 2018
3a51e9c
Fix doctest for InspectableSet
jace Feb 20, 2018
7ec7561
Tweak ModelView for better sanity
jace Feb 22, 2018
e70cf51
Fix url_name_suuid breakage with RoleMixin's proxy
jace Feb 22, 2018
43ec78e
Misc cleanup in StateManager
jace Feb 22, 2018
a54d23d
Added url_change_check decorator and mixin class
jace Feb 22, 2018
ec3c402
Fix url_name_suuid+test
jace Feb 22, 2018
c03a035
Add an after_request handler to ClassView
jace Feb 22, 2018
a93fccf
Drop self.kwargs in ModelView and update documentation
jace Feb 22, 2018
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
* New: ``coaster.auth`` module with a ``current_auth`` proxy that provides
a standardised API for login managers to use
* New: ``is_collection`` util for testing if an item is a collection data type
* New: ``coaster.views.requires_permission`` decorator
* New: ``coaster.views.classview`` provides a new take on organising views
into a class


0.6.0
Expand Down
3 changes: 3 additions & 0 deletions coaster/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from flask.json import tojson_filter as _tojson_filter
from . import logger
from .auth import current_auth
from .views import current_view

__all__ = ['SandboxedFlask', 'init_app']

Expand Down Expand Up @@ -103,6 +104,8 @@ def init_app(app, env=None):
"""
# Make current_auth available to app templates
app.jinja_env.globals['current_auth'] = current_auth
# Make the current view available to app templates
app.jinja_env.globals['current_view'] = current_view
# Disable Flask-SQLAlchemy events.
# Apps that want it can turn it back on in their config
app.config.setdefault('SQLALCHEMY_TRACK_MODIFICATIONS', False)
Expand Down
13 changes: 13 additions & 0 deletions coaster/sqlalchemy/comparators.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from __future__ import absolute_import
import uuid as uuid_
from sqlalchemy.ext.hybrid import Comparator
from flask import abort
from flask_sqlalchemy import BaseQuery
import six
from ..utils import buid2uuid, suuid2uuid
Expand Down Expand Up @@ -38,6 +39,18 @@ def isempty(self):
"""
return not self.session.query(self.exists()).scalar()

def one_or_404(self):
"""
Extends :meth:`~sqlalchemy.orm.query.Query.one_or_none` to raise a 404
if no result is found. This method offers a safety net over
:meth:`~flask_sqlalchemy.BaseQuery.first_or_404` as it helps identify
poorly specified queries that could have returned more than one result.
"""
result = self.one_or_none()
if not result:
abort(404)
return result


class SplitIndexComparator(Comparator):
"""
Expand Down
35 changes: 29 additions & 6 deletions coaster/sqlalchemy/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ class MyModel(BaseMixin, db.Model):
from sqlalchemy_utils.types import UUIDType
from flask import url_for
import six
from ..utils import make_name, uuid2suuid, uuid2buid, buid2uuid, suuid2uuid
from ..utils import make_name, uuid2suuid, uuid2buid, buid2uuid, suuid2uuid, InspectableSet
from ..auth import current_auth
from .immutable_annotation import immutable
from .roles import RoleMixin, with_roles
from .comparators import Query, SqlSplitIdComparator, SqlHexUuidComparator, SqlBuidComparator, SqlSuuidComparator
Expand Down Expand Up @@ -225,6 +226,15 @@ def permissions(self, user, inherited=None):
else:
return set()

@property
def current_permissions(self):
"""
:class:`~coaster.utils.classes.InspectableSet` containing currently
available permissions from this object, using
:obj:`~coaster.auth.current_auth`.
"""
return InspectableSet(self.permissions(current_auth.actor))


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

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

def __repr__(self):
return '<%s %s "%s">' % (self.__class__.__name__, self.url_name, self.title)
return '<%s %s "%s">' % (self.__class__.__name__, self.url_id_name, self.title)

def make_name(self):
"""Autogenerates a :attr:`name` from the :attr:`title`"""
Expand Down Expand Up @@ -571,11 +584,19 @@ def url_name_suuid(self):
Returns a URL name combining :attr:`name` and :attr:`suuid` in name-suuid syntax.
To use this, the class must derive from :class:`UuidMixin`.
"""
return '%s-%s' % (self.name, self.suuid)
if isinstance(self, UuidMixin):
return '%s-%s' % (self.name, self.suuid)
else:
return '%s-%s' % (self.name, self.url_id)

@url_name_suuid.comparator
def url_name_suuid(cls):
return SqlSuuidComparator(cls.uuid, splitindex=-1)
if issubclass(cls, UuidMixin):
return SqlSuuidComparator(cls.uuid, splitindex=-1)
elif cls.__uuid_primary_key__:
return SqlHexUuidComparator(cls.id, splitindex=-1)
else:
return SqlSplitIdComparator(cls.id, splitindex=-1)


class BaseScopedIdMixin(BaseMixin):
Expand Down Expand Up @@ -624,8 +645,10 @@ def permissions(self, user, inherited=None):
"""
if inherited is not None:
return inherited | super(BaseScopedIdMixin, self).permissions(user)
else:
elif self.parent is not None and isinstance(self.parent, PermissionMixin):
return self.parent.permissions(user) | super(BaseScopedIdMixin, self).permissions(user)
else:
return super(BaseScopedIdMixin, self).permissions(user)


class BaseScopedIdNameMixin(BaseScopedIdMixin):
Expand Down Expand Up @@ -682,7 +705,7 @@ def __init__(self, *args, **kw):
self.make_name()

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

@classmethod
Expand Down
16 changes: 11 additions & 5 deletions coaster/sqlalchemy/statemanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,9 @@ def add_transition(self, statemanager, from_, to, if_=None, data=None):
'if': if_, # Additional conditions that must ALL pass
}

def __set_name__(self, owner, name): # pragma: no cover
self.name = name

# Make the transition a non-data descriptor
def __get__(self, obj, cls=None):
if obj is None:
Expand Down Expand Up @@ -501,6 +504,9 @@ def is_available(self):
"""
return not self._state_invalid()

def __getattr__(self, name):
return getattr(self.statetransition, name)

def __call__(self, *args, **kwargs):
"""Call the transition"""
# Validate that each of the state managers is in the correct state
Expand Down Expand Up @@ -827,17 +833,17 @@ def group(self, items, keep_empty=False):
del groups[key]
return groups

def __getattr__(self, attr):
def __getattr__(self, name):
"""
Given the name of a state, returns:
1. If called on an instance, a boolean indicating if the state is active
1. If called on an instance, a ManagedStateWrapper, which implements __bool__
2. If called on a class, a query filter
Returns the default value or raises :exc:`AttributeError` on anything else.
"""
if hasattr(self.statemanager, attr):
mstate = getattr(self.statemanager, attr)
if hasattr(self.statemanager, name):
mstate = getattr(self.statemanager, name)
if isinstance(mstate, (ManagedState, ManagedStateGroup)):
return mstate(self.obj, self.cls)
raise AttributeError("Not a state: %s" % attr)
raise AttributeError("Not a state: %s" % name)
21 changes: 18 additions & 3 deletions coaster/utils/classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,9 +214,10 @@ def value_for(cls, name):

class InspectableSet(Set):
"""
Given a set, mimics a dictionary where the items are keys and have a value
of ``True``, and any other key has a value of ``False``. Also supports
attribute access. Useful in templates to simplify membership inspection::
Given a set, mimics a read-only dictionary where the items are keys and
have a value of ``True``, and any other key has a value of ``False``. Also
supports attribute access. Useful in templates to simplify membership
inspection::
>>> myset = InspectableSet({'member', 'other'})
>>> 'member' in myset
Expand All @@ -231,6 +232,20 @@ class InspectableSet(Set):
True
>>> myset['random']
False
>>> joinset = myset | {'added'}
>>> isinstance(joinset, InspectableSet)
True
>>> joinset = joinset | InspectableSet({'inspectable'})
>>> isinstance(joinset, InspectableSet)
True
>>> 'member' in joinset
True
>>> 'other' in joinset
True
>>> 'added' in joinset
True
>>> 'inspectable' in joinset
True
"""
def __init__(self, members):
if not isinstance(members, set):
Expand Down
1 change: 1 addition & 0 deletions coaster/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@
from __future__ import absolute_import
from .misc import * # NOQA
from .decorators import * # NOQA
from .classview import * # NOQA
Loading