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

Rename set_roles to with_roles and add support for declared_attr and column properties #140

Merged
merged 2 commits into from
Aug 31, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
@@ -1,6 +1,9 @@
0.6.1
-----

* Renamed ``coaster.roles.set_roles`` to ``with_roles`` and added support for
wrapping ``declared_attr`` and column properties


0.6.0
-----
Expand Down
121 changes: 90 additions & 31 deletions coaster/roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,48 +25,71 @@

from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from coaster.sqlalchemy import BaseMixin, set_roles
from coaster.sqlalchemy import BaseMixin, with_roles

app = Flask(__name__)
db = SQLAlchemy(app)

class DeclaredAttrMixin(object):
# The ugly way to work with declared_attr
@declared_attr
# Standard usage
@with_roles(rw={'owner'})
def mixed_in1(cls):
return set_roles(db.Column(db.Unicode(250)),
return db.Column(db.Unicode(250))

# Roundabout approach
@declared_attr
def mixed_in2(cls):
return with_roles(db.Column(db.Unicode(250)),
rw={'owner'})

# The clean way to work with declared_attr
# Deprecated since 0.6.1
@declared_attr
@declared_attr_roles(rw={'owner', 'editor'}, read={'all'})
def mixed_in2(cls):
def mixed_in3(cls):
return db.Column(db.Unicode(250))


class RoleModel(DeclaredAttrMixin, RoleMixin, db.Model):
__tablename__ = 'role_model'

# Approach one, declare roles in advance.
# 'all' is a special role that is always granted from the base class
# The low level approach is to declare roles in advance.
# 'all' is a special role that is always granted from the base class.
# Avoid this approach because you may accidentally lose roles defined
# in base classes.

__roles__ = {
'all': {
'read': {'id', 'name', 'title'}
}
}

# Approach two, annotate roles on the attributes.
# These annotations always add to anything specified in __roles__
# Recommended: annotate roles on the attributes using ``with_roles``.
# These annotations always add to anything specified in ``__roles__``.

id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.Unicode(250))
set_roles(name, rw={'owner'}) # Specify read+write access
name = with_roles(db.Column(db.Unicode(250)),
rw={'owner'}) # Specify read+write access

# ``with_roles`` can also be called later. This is typically required
# for properties, where roles must be assigned after the property is
# fully described.

_title = db.Column('title', db.Unicode(250))

@property
def title(self):
return self._title

@title.setter
def title(self, value):
self._title = value

title = db.Column(db.Unicode(250))
set_roles(title, write={'owner', 'editor'}) # Grant 'owner' and 'editor' write but not read access
title = with_roles(title, write={'owner', 'editor'}) # This grants 'owner' and 'editor' write but not read access

@set_roles(call={'all'}) # 'call' is an alias for 'read', to be used for clarity
# ``with_roles`` can be used as a decorator on functions.
# 'call' is an alias for 'read', to be used for clarity.

@with_roles(call={'all'})
def hello(self):
return "Hello!"

Expand All @@ -86,8 +109,13 @@ def roles_for(self, user=None, token=None):
import collections
from copy import deepcopy
from sqlalchemy import event
from sqlalchemy.orm import mapper
from sqlalchemy.orm.attributes import QueryableAttribute

__all__ = ['RoleAccessProxy', 'RoleMixin', 'with_roles', 'declared_attr_roles']

__all__ = ['RoleAccessProxy', 'RoleMixin', 'set_roles', 'declared_attr_roles']
# Global dictionary for temporary storage of roles until the mapper_configured events
__cache__ = {}


class RoleAccessProxy(collections.Mapping):
Expand Down Expand Up @@ -132,7 +160,7 @@ def __init__(self, obj, roles):
self.__dict__['_write'] = write

def __repr__(self): # pragma: no-cover
return 'RoleAccessProxy(obj={obj}, roles={roles})'.format(
return '<RoleAccessProxy(obj={obj}, roles={roles})>'.format(
obj=repr(self._obj), roles=repr(self._roles))

def __getattr__(self, attr):
Expand Down Expand Up @@ -170,7 +198,7 @@ def __iter__(self):
yield key


def set_roles(obj=None, rw=None, call=None, read=None, write=None):
def with_roles(obj=None, rw=None, call=None, read=None, write=None):
"""
Convenience function and decorator to define roles on an attribute. Only
works with :class:`RoleMixin`, which reads the annotations made by this
Expand All @@ -179,9 +207,9 @@ def set_roles(obj=None, rw=None, call=None, read=None, write=None):
Examples::

id = db.Column(Integer, primary_key=True)
set_roles(id, read={'all'})
with_roles(id, read={'all'})

@set_roles(read={'all'})
@with_roles(read={'all'})
@hybrid_property
def url_id(self):
return str(self.id)
Expand All @@ -206,7 +234,14 @@ def url_id(self):
write.update(rw)

def inner(attr):
attr._coaster_roles = {'read': read, 'write': write}
__cache__[attr] = {'read': read, 'write': write}
try:
attr._coaster_roles = {'read': read, 'write': write}
# If the attr has a restrictive __slots__, we'll get an attribute error.
# Use of _coaster_roles is now legacy, for declared_attr_roles, so we
# can safely ignore the error.
except AttributeError:
pass
return attr

if isinstance(obj, (list, tuple, set)):
Expand All @@ -217,28 +252,35 @@ def inner(attr):
else:
return inner

# with_roles was set_roles when originally introduced in 0.6.0
set_roles = with_roles


def declared_attr_roles(rw=None, call=None, read=None, write=None):
"""
Equivalent of :func:`set_roles` for use with ``@declared_attr``::
Equivalent of :func:`with_roles` for use with ``@declared_attr``::

@declared_attr
@declared_attr_roles(read={'all'})
def my_column(cls):
return Column(Integer)

While :func:`set_roles` is always the outermost decorator on properties
While :func:`with_roles` is always the outermost decorator on properties
and functions, :func:`declared_attr_roles` must appear below
``@declared_attr`` to work correctly.

.. deprecated:: 0.6.1
Use :func:`with_roles` instead. It works for
:class:`~sqlalchemy.ext.declarative.declared_attr` since 0.6.1
"""
def inner(f):
@wraps(f)
def attr(cls):
# Pass f(cls) as a parameter to set_roles.inner to avoid the test for
# iterables within set_roles. We have no idea about the use cases for
# Pass f(cls) as a parameter to with_roles.inner to avoid the test for
# iterables within with_roles. We have no idea about the use cases for
# declared_attr in downstream code. There could be a declared_attr
# that returns a list that should be accessible via the proxy.
return set_roles(rw=rw, call=call, read=read, write=write)(f(cls))
return with_roles(rw=rw, call=call, read=read, write=write)(f(cls))
return attr
return inner

Expand Down Expand Up @@ -326,7 +368,7 @@ def access_for(self, roles=None, user=None, token=None):
def __configure_roles(mapper, cls):
"""
Run through attributes of the class looking for role decorations from
:func:`set_roles` and add them to :attr:`cls.__roles__`
:func:`with_roles` and add them to :attr:`cls.__roles__`
"""
# Don't mutate __roles__ in the base class.
# The subclass must have its own.
Expand All @@ -345,9 +387,26 @@ def __configure_roles(mapper, cls):
for name, attr in base.__dict__.items():
if name in processed or name.startswith('__'):
continue
processed.add(name)
if hasattr(attr, '_coaster_roles'):
for role in attr._coaster_roles.get('read', []):

if isinstance(attr, collections.Hashable) and attr in __cache__:
data = __cache__[attr]
del __cache__[attr]
elif isinstance(attr, QueryableAttribute) and attr.property in __cache__:
data = __cache__[attr.property]
del __cache__[attr.property]
elif hasattr(attr, '_coaster_roles'): # XXX: Deprecated
data = attr._coaster_roles
else:
data = None
if data is not None:
for role in data.get('read', []):
cls.__roles__.setdefault(role, {}).setdefault('read', set()).add(name)
for role in attr._coaster_roles.get('write', []):
for role in data.get('write', []):
cls.__roles__.setdefault(role, {}).setdefault('write', set()).add(name)
processed.add(name)


@event.listens_for(mapper, 'after_configured')
def __clear_cache():
for key in tuple(__cache__):
del __cache__[key]
22 changes: 11 additions & 11 deletions coaster/sqlalchemy.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ class MyModel(BaseMixin, db.Model):
from flask import Markup, url_for
from flask_sqlalchemy import BaseQuery
from .utils import make_name, uuid2buid, uuid2suuid, buid2uuid, suuid2uuid
from .roles import RoleMixin, set_roles, declared_attr_roles # NOQA
from .roles import RoleMixin, with_roles, set_roles, declared_attr_roles # NOQA
from .gfm import markdown
import six

Expand Down Expand Up @@ -280,8 +280,8 @@ class MyDocument(UuidMixin, BaseMixin, db.Model):
| BaseScopedIdNameMixin | No | Conflicting :attr:`url_id` attribute |
+-----------------------+-------------+-----------------------------------------+
"""
@with_roles(read={'all'})
@declared_attr
@declared_attr_roles(read={'all'})
def uuid(cls):
"""UUID column, or synonym to existing :attr:`id` column if that is a UUID"""
if hasattr(cls, '__uuid_primary_key__') and cls.__uuid_primary_key__:
Expand All @@ -303,7 +303,7 @@ def url_id(cls):
else:
return SqlHexUuidComparator(cls.uuid)

set_roles(url_id, read={'all'})
url_id = with_roles(url_id, read={'all'})

@hybrid_property
def buid(self):
Expand All @@ -318,7 +318,7 @@ def buid(self, value):
def buid(cls):
return SqlBuidComparator(cls.uuid)

set_roles(buid, read={'all'})
buid = with_roles(buid, read={'all'})

@hybrid_property
def suuid(self):
Expand All @@ -333,7 +333,7 @@ def suuid(self, value):
def suuid(cls):
return SqlSuuidComparator(cls.uuid)

set_roles(suuid, read={'all'})
suuid = with_roles(suuid, read={'all'})


# Setup listeners for UUID-based subclasses
Expand Down Expand Up @@ -704,7 +704,7 @@ def make_name(self):
if self.title:
self.name = six.text_type(make_name(self.title, maxlength=self.__name_length__))

@set_roles(read={'all'})
@with_roles(read={'all'})
@hybrid_property
def url_id_name(self):
"""
Expand All @@ -725,7 +725,7 @@ def url_id_name(cls):

url_name = url_id_name # Legacy name

@set_roles(read={'all'})
@with_roles(read={'all'})
@hybrid_property
def url_name_suuid(self):
"""
Expand Down Expand Up @@ -753,8 +753,8 @@ class Issue(BaseScopedIdMixin, db.Model):
parent = db.synonym('event')
__table_args__ = (db.UniqueConstraint('event_id', 'url_id'),)
"""
@with_roles(read={'all'})
@declared_attr
@declared_attr_roles(read={'all'})
def url_id(cls):
"""Contains an id number that is unique within the parent container"""
return Column(Integer, nullable=False)
Expand Down Expand Up @@ -856,7 +856,7 @@ def make_name(self):
if self.title:
self.name = six.text_type(make_name(self.title, maxlength=self.__name_length__))

@set_roles(read={'all'})
@with_roles(read={'all'})
@hybrid_property
def url_id_name(self):
"""Returns a URL name combining :attr:`url_id` and :attr:`name` in id-name syntax"""
Expand Down Expand Up @@ -1059,8 +1059,8 @@ def MarkdownColumn(name, deferred=False, group=None, **kwargs):

# --- Helper functions --------------------------------------------------------

__all_functions = ['failsafe_add', 'set_roles', 'declared_attr_roles', 'add_primary_relationship',
'auto_init_default']
__all_functions = ['failsafe_add', 'with_roles', 'set_roles', 'declared_attr_roles',
'add_primary_relationship', 'auto_init_default']


def failsafe_add(_session, _instance, **filters):
Expand Down
Loading