# -*- coding: utf-8 -*-
from __future__ import absolute_import
from datetime import datetime
import simplejson
from sqlalchemy import Column, Integer, DateTime, Unicode, UnicodeText, CheckConstraint, Numeric
from sqlalchemy.sql import select, func
from sqlalchemy.types import UserDefinedType, TypeDecorator, TEXT
from sqlalchemy.orm import composite
from sqlalchemy.orm.exc import NoResultFound
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.ext.mutable import Mutable, MutableComposite
from flask import Markup
from flask.ext.sqlalchemy import BaseQuery
from .utils import make_name
from .gfm import markdown
__all_mixins = ['IdMixin', 'TimestampMixin', 'PermissionMixin', 'UrlForMixin',
'BaseMixin', 'BaseNameMixin', 'BaseScopedNameMixin', 'BaseIdNameMixin',
'BaseScopedIdMixin', 'BaseScopedIdNameMixin', 'CoordinatesMixin']
class Query(BaseQuery):
"""
Extends flask.ext.sqlalchemy.BaseQuery to add additional helper methods.
"""
def one_or_none(self):
"""
Like :meth:`one` but returns None if no results are found. Raises an exception
if multiple results are found.
"""
try:
return self.one()
except NoResultFound:
return None
def notempty(self):
"""
Returns the equivalent of ``bool(query.count())`` but using an efficient
SQL EXISTS function, so the database stops counting after the first result
is found.
"""
return self.session.query(self.exists()).first()[0]
def isempty(self):
"""
Returns the equivalent of ``not bool(query.count())`` but using an efficient
SQL EXISTS function, so the database stops counting after the first result
is found.
"""
return not self.session.query(self.exists()).first()[0]
[docs]class IdMixin(object):
"""
Provides the :attr:`id` primary key column
"""
query_class = Query
#: Database identity for this model, used for foreign key
#: references from other models
id = Column(Integer, primary_key=True)
#: ..deprecated: 0.4.3
#: Use :func:`make_timestamp_columns` instead. These columns will be assigned to
#: the first model you use them with and can't be used again in another model
timestamp_columns = (
Column('created_at', DateTime, default=datetime.utcnow, nullable=False),
Column('updated_at', DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False),
)
def make_timestamp_columns():
return (
Column('created_at', DateTime, default=datetime.utcnow, nullable=False),
Column('updated_at', DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False),
)
[docs]class TimestampMixin(object):
"""
Provides the :attr:`created_at` and :attr:`updated_at` audit timestamps
"""
query_class = Query
#: Timestamp for when this instance was created, in UTC
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
#: Timestamp for when this instance was last updated (via the app), in UTC
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
[docs]class PermissionMixin(object):
"""
Provides the :meth:`permissions` method used by BaseMixin and derived classes
"""
[docs] def permissions(self, user, inherited=None):
"""
Return permissions available to the given user on this object
"""
if inherited is not None:
return set(inherited)
else:
return set()
[docs]class BaseMixin(IdMixin, TimestampMixin, PermissionMixin, UrlForMixin):
"""
Base mixin class for all tables that adds id and timestamp columns and includes
stub :meth:`permissions` and :meth:`url_for` methods
"""
def _set_fields(self, fields):
for f in fields:
if hasattr(self, f):
setattr(self, f, fields[f])
else:
raise TypeError("'{arg}' is an invalid argument for {instance_type}".format(arg=f, instance_type=self.__class__.__name__))
[docs]class BaseNameMixin(BaseMixin):
"""
Base mixin class for named objects
.. versionchanged:: 0.5.0
If you used BaseNameMixin in your app before Coaster 0.5.0:
:attr:`name` can no longer be a blank string in addition to being
non-null. This is configurable and enforced with a SQL CHECK constraint,
which needs a database migration:
::
for tablename in ['named_table1', 'named_table2', ...]:
# Drop CHECK constraint first in case it was already present
op.drop_constraint(tablename + '_name_check', tablename)
# Create CHECK constraint
op.create_check_constraint(tablename + '_name_check', tablename, "name!=''")
"""
#: Prevent use of these reserved names
reserved_names = []
#: Allow blank names after all?
__name_blank_allowed__ = False
#: How long should names and titles be?
__name_length__ = __title_length__ = 250
@declared_attr
def name(cls):
"""The URL name of this object, unique across all instances of this model"""
if cls.__name_blank_allowed__:
return Column(Unicode(cls.__name_length__), nullable=False, unique=True)
else:
return Column(Unicode(cls.__name_length__), CheckConstraint("name!=''"), nullable=False, unique=True)
@declared_attr
def title(cls):
"""The title of this object"""
return Column(Unicode(cls.__title_length__), nullable=False)
def __init__(self, *args, **kw):
super(BaseNameMixin, self).__init__(*args, **kw)
if not self.name:
self.make_name()
@classmethod
[docs] def get(cls, name):
"""Get an instance matching the name"""
return cls.query.filter_by(name=name).one_or_none()
@classmethod
[docs] def upsert(cls, name, **fields):
"""Insert or update an instance"""
instance = cls.get(name)
if instance:
instance._set_fields(fields)
else:
instance = cls(name=name, **fields)
cls.query.session.add(instance)
return instance
[docs] def make_name(self, reserved=[]):
"""
Autogenerates a :attr:`name` from the :attr:`title`. If the auto-generated name is already
in use in this model, :meth:`make_name` tries again by suffixing numbers starting with 2
until an available name is found.
:param reserved: List or set of reserved names unavailable for use
"""
if self.title:
if self.id:
checkused = lambda c: bool(c in reserved or c in self.reserved_names or
self.__class__.query.filter(self.__class__.id != self.id).filter_by(name=c).notempty())
else:
checkused = lambda c: bool(c in reserved or c in self.reserved_names or
self.__class__.query.filter_by(name=c).notempty())
with self.__class__.query.session.no_autoflush:
self.name = unicode(make_name(self.title, maxlength=self.__name_length__, checkused=checkused))
[docs]class BaseScopedNameMixin(BaseMixin):
"""
Base mixin class for named objects within containers. When using this,
you must provide an model-level attribute "parent" that is a synonym for
the parent object. You must also create a unique constraint on 'name' in
combination with the parent foreign key. Sample use case in Flask::
class Event(BaseScopedNameMixin, db.Model):
__tablename__ = 'event'
organizer_id = db.Column(None, db.ForeignKey('organizer.id'))
organizer = db.relationship(Organizer)
parent = db.synonym('organizer')
__table_args__ = (db.UniqueConstraint('organizer_id', 'name'),)
.. versionchanged:: 0.5.0
If you used BaseScopedNameMixin in your app before Coaster 0.5.0:
:attr:`name` can no longer be a blank string in addition to being
non-null. This is configurable and enforced with a SQL CHECK constraint,
which needs a database migration:
::
for tablename in ['named_table1', 'named_table2', ...]:
# Drop CHECK constraint first in case it was already present
op.drop_constraint(tablename + '_name_check', tablename)
# Create CHECK constraint
op.create_check_constraint(tablename + '_name_check', tablename, "name!=''")
"""
#: Prevent use of these reserved names
reserved_names = []
#: Allow blank names after all?
__name_blank_allowed__ = False
#: How long should names and titles be?
__name_length__ = __title_length__ = 250
@declared_attr
def name(cls):
"""The URL name of this object, unique within a parent container"""
if cls.__name_blank_allowed__:
return Column(Unicode(cls.__name_length__), nullable=False)
else:
return Column(Unicode(cls.__name_length__), CheckConstraint("name!=''"), nullable=False)
@declared_attr
def title(cls):
"""The title of this object"""
return Column(Unicode(cls.__title_length__), nullable=False)
def __init__(self, *args, **kw):
super(BaseScopedNameMixin, self).__init__(*args, **kw)
if self.parent and not self.name:
self.make_name()
@classmethod
[docs] def get(cls, parent, name):
"""Get an instance matching the parent and name"""
return cls.query.filter_by(parent=parent, name=name).one_or_none()
@classmethod
[docs] def upsert(cls, parent, name, **fields):
"""Insert or update an instance"""
instance = cls.get(parent, name)
if instance:
instance._set_fields(fields)
else:
instance = cls(parent=parent, name=name, **fields)
cls.query.session.add(instance)
return instance
[docs] def make_name(self, reserved=[]):
"""
Autogenerates a :attr:`name` from the :attr:`title`. If the auto-generated name is already
in use in this model, :meth:`make_name` tries again by suffixing numbers starting with 2
until an available name is found.
"""
if self.title:
if self.id:
checkused = lambda c: bool(c in reserved or c in self.reserved_names or
self.__class__.query.filter(self.__class__.id != self.id).filter_by(
name=c, parent=self.parent).first())
else:
checkused = lambda c: bool(c in reserved or c in self.reserved_names or
self.__class__.query.filter_by(name=c, parent=self.parent).first())
with self.__class__.query.session.no_autoflush:
self.name = unicode(make_name(self.short_title(), maxlength=self.__name_length__, checkused=checkused))
[docs] def short_title(self):
"""
Generates an abbreviated title by subtracting the parent's title from this instance's title.
"""
if self.title and self.parent is not None and hasattr(self.parent, 'title') and self.parent.title:
if self.title.startswith(self.parent.title):
short = self.title[len(self.parent.title):].strip()
if short:
return short
return self.title
[docs] def permissions(self, user, inherited=None):
"""
Permissions for this model, plus permissions inherited from the parent.
"""
if inherited is not None:
return inherited | super(BaseScopedNameMixin, self).permissions(user)
elif self.parent is not None and isinstance(self.parent, PermissionMixin):
return self.parent.permissions(user) | super(BaseScopedNameMixin, self).permissions(user)
else:
return super(BaseScopedNameMixin, self).permissions(user)
[docs]class BaseIdNameMixin(BaseMixin):
"""
Base mixin class for named objects with an id tag.
.. versionchanged:: 0.5.0
If you used BaseIdNameMixin in your app before Coaster 0.5.0:
:attr:`name` can no longer be a blank string in addition to being
non-null. This is configurable and enforced with a SQL CHECK constraint,
which needs a database migration:
::
for tablename in ['named_table1', 'named_table2', ...]:
# Drop CHECK constraint first in case it was already present
op.drop_constraint(tablename + '_name_check', tablename)
# Create CHECK constraint
op.create_check_constraint(tablename + '_name_check', tablename, "name!=''")
"""
#: Allow blank names after all?
__name_blank_allowed__ = False
#: How long should names and titles be?
__name_length__ = __title_length__ = 250
@declared_attr
def name(cls):
"""The URL name of this object, non-unique"""
if cls.__name_blank_allowed__:
return Column(Unicode(cls.__name_length__), nullable=False)
else:
return Column(Unicode(cls.__name_length__), CheckConstraint("name!=''"), nullable=False)
@declared_attr
def title(cls):
"""The title of this object"""
return Column(Unicode(cls.__title_length__), nullable=False)
#: The attribute containing id numbers used in the URL in id-name syntax, for external reference
url_id_attr = 'id'
def __init__(self, *args, **kw):
super(BaseIdNameMixin, self).__init__(*args, **kw)
if not self.name:
self.make_name()
[docs] def make_name(self):
"""Autogenerates a :attr:`name` from the :attr:`title`"""
if self.title:
self.name = unicode(make_name(self.title, maxlength=self.__name_length__))
@property
def url_id(self):
"""Return the URL id"""
return self.id
@property
def url_name(self):
"""Returns a URL name combining :attr:`url_id` and :attr:`name` in id-name syntax"""
return '%d-%s' % (self.url_id, self.name)
[docs]class BaseScopedIdMixin(BaseMixin):
"""
Base mixin class for objects with an id that is unique within a parent.
Implementations must provide a 'parent' attribute that is either a relationship
or a synonym to a relationship referring to the parent object, and must
declare a unique constraint between url_id and the parent. Sample use case in Flask::
class Issue(BaseScopedIdMixin, db.Model):
__tablename__ = 'issue'
event_id = db.Column(None, db.ForeignKey('event.id'))
event = db.relationship(Event)
parent = db.synonym('event')
__table_args__ = (db.UniqueConstraint('event_id', 'url_id'),)
"""
@declared_attr
def url_id(cls):
"""Contains an id number that is unique within the parent container"""
return Column(Integer, nullable=False)
#: The attribute containing the url id value, for external reference
url_id_attr = 'url_id'
def __init__(self, *args, **kw):
super(BaseScopedIdMixin, self).__init__(*args, **kw)
if self.parent:
self.make_id()
@classmethod
[docs] def get(cls, parent, url_id):
"""Get an instance matching the parent and url_id"""
return cls.query.filter_by(parent=parent, url_id=url_id).one_or_none()
[docs] def make_id(self):
"""Create a new URL id that is unique to the parent container"""
if self.url_id is None: # Set id only if empty
self.url_id = select([func.coalesce(func.max(self.__class__.url_id + 1), 1)],
self.__class__.parent == self.parent)
[docs] def permissions(self, user, inherited=None):
"""
Permissions for this model, plus permissions inherited from the parent.
"""
if inherited is not None:
return inherited | super(BaseScopedIdMixin, self).permissions(user)
else:
return self.parent.permissions(user) | super(BaseScopedIdMixin, self).permissions(user)
[docs]class BaseScopedIdNameMixin(BaseScopedIdMixin):
"""
Base mixin class for named objects with an id tag that is unique within a
parent. Implementations must provide a 'parent' attribute that is a
synonym to the parent relationship, and must declare a unique constraint
between url_id and the parent. Sample use case in Flask::
class Event(BaseScopedIdNameMixin, db.Model):
__tablename__ = 'event'
organizer_id = db.Column(None, db.ForeignKey('organizer.id'))
organizer = db.relationship(Organizer)
parent = db.synonym('organizer')
__table_args__ = (db.UniqueConstraint('organizer_id', 'url_id'),)
.. versionchanged:: 0.5.0
If you used BaseScopedIdNameMixin in your app before Coaster 0.5.0:
:attr:`name` can no longer be a blank string in addition to being
non-null. This is configurable and enforced with a SQL CHECK constraint,
which needs a database migration:
::
for tablename in ['named_table1', 'named_table2', ...]:
# Drop CHECK constraint first in case it was already present
op.drop_constraint(tablename + '_name_check', tablename)
# Create CHECK constraint
op.create_check_constraint(tablename + '_name_check', tablename, "name!=''")
"""
#: Allow blank names after all?
__name_blank_allowed__ = False
#: How long should names and titles be?
__name_length__ = __title_length__ = 250
@declared_attr
def name(cls):
"""The URL name of this instance, non-unique"""
if cls.__name_blank_allowed__:
return Column(Unicode(cls.__name_length__), nullable=False)
else:
return Column(Unicode(cls.__name_length__), CheckConstraint("name!=''"), nullable=False)
@declared_attr
def title(cls):
"""The title of this instance"""
return Column(Unicode(cls.__title_length__), nullable=False)
def __init__(self, *args, **kw):
super(BaseScopedIdNameMixin, self).__init__(*args, **kw)
if self.parent:
self.make_id()
if not self.name:
self.make_name()
@classmethod
[docs] def get(cls, parent, url_id):
"""Get an instance matching the parent and name"""
return cls.query.filter_by(parent=parent, url_id=url_id).one_or_none()
[docs] def make_name(self):
"""Autogenerates a title from the name"""
if self.title:
self.name = unicode(make_name(self.title, maxlength=self.__name_length__))
@property
def url_name(self):
"""Returns a URL name combining :attr:`url_id` and :attr:`name` in id-name syntax"""
return '%d-%s' % (self.url_id, self.name)
[docs]class CoordinatesMixin(object):
"""
Adds :attr:`latitude` and :attr:`longitude` columns with a shorthand :attr:`coordinates`
property that returns both.
"""
latitude = Column(Numeric)
longitude = Column(Numeric)
@property
def coordinates(self):
return self.latitude, self.longitude
@coordinates.setter
def coordinates(self, value):
self.latitude, self.longitude = value
# --- Column types ------------------------------------------------------------
__all_columns = ['JsonDict', 'MarkdownComposite', 'MarkdownColumn']
class JsonType(UserDefinedType):
"""The PostgreSQL JSON type."""
def get_col_spec(self):
return "JSON"
class JsonbType(UserDefinedType):
"""The PostgreSQL JSONB type."""
def get_col_spec(self):
return "JSONB"
# Adapted from http://docs.sqlalchemy.org/en/rel_0_8/orm/extensions/mutable.html#establishing-mutability-on-scalar-column-values
[docs]class JsonDict(TypeDecorator):
"""
Represents a JSON data structure. Usage::
column = Column(JsonDict)
"""
impl = TEXT
def load_dialect_impl(self, dialect):
if dialect.name == 'postgresql':
version = tuple(dialect.server_version_info[:2])
if version in [(9, 2), (9, 3)]:
return dialect.type_descriptor(JsonType)
elif version >= (9, 4):
return dialect.type_descriptor(JsonbType)
return dialect.type_descriptor(self.impl)
def process_bind_param(self, value, dialect):
if value is not None:
value = simplejson.dumps(value, default=lambda o: unicode(o))
return value
def process_result_value(self, value, dialect):
if value is not None and isinstance(value, basestring):
# Psycopg2 >= 2.5 will auto-decode JSON columns, so
# we only attempt decoding if the value is a string.
# Since this column stores dicts only, processed values
# can never be strings.
value = simplejson.loads(value, use_decimal=True)
return value
class MutableDict(Mutable, dict):
@classmethod
def coerce(cls, key, value):
"Convert plain dictionaries to MutableDict."
if not isinstance(value, MutableDict):
if isinstance(value, dict):
return MutableDict(value)
elif isinstance(value, basestring):
# Assume JSON string
if value:
return MutableDict(simplejson.loads(value, use_decimal=True))
else:
return MutableDict() # Empty value is an empty dict
# this call will raise ValueError
return Mutable.coerce(key, value)
else:
return value
def __setitem__(self, key, value):
"Detect dictionary set events and emit change events."
dict.__setitem__(self, key, value)
self.changed()
def __delitem__(self, key):
"Detect dictionary del events and emit change events."
dict.__delitem__(self, key)
self.changed()
MutableDict.associate_with(JsonDict)
[docs]class MarkdownComposite(MutableComposite):
"""
Represents GitHub-flavoured Markdown text and rendered HTML as a composite column.
"""
def __init__(self, text, html=None):
if html is None:
self.text = text # This will regenerate HTML
else:
object.__setattr__(self, 'text', text)
object.__setattr__(self, '_html', html)
# If the text value is set, regenerate HTML, then notify parents of the change
def __setattr__(self, key, value):
if key == 'text':
object.__setattr__(self, '_html', markdown(value))
object.__setattr__(self, key, value)
self.changed()
# Return column values for SQLAlchemy to insert into the database
def __composite_values__(self):
return (self.text, self._html)
# Return a string representation of the text
def __str__(self):
return str(self.text)
# Return a unicode representation of the text
def __unicode__(self):
return unicode(self.text)
# Return a HTML representation of the text
def __html__(self):
return self._html or u''
# Return a Markup string of the HTML
@property
def html(self):
return Markup(self._html or u'')
# Compare text value
def __eq__(self, other):
return (self.text == other.text) if isinstance(other, MarkdownComposite) else (self.text == other)
def __ne__(self, other):
return not self.__eq__(other)
# Return state for pickling
def __getstate__(self):
return (self.text, self._html)
# Set state from pickle
def __setstate__(self, state):
object.__setattr__(self, 'text', state[0])
object.__setattr__(self, '_html', state[1])
self.changed()
def __nonzero__(self):
return bool(self.text)
__bool__ = __nonzero__
# Allow a composite column to be assigned a string value
@classmethod
def coerce(cls, key, value):
return cls(value)
def MarkdownColumn(name, deferred=False, group=None, **kwargs):
return composite(MarkdownComposite,
Column(name + '_text', UnicodeText, **kwargs),
Column(name + '_html', UnicodeText, **kwargs),
deferred=deferred, group=group or name
)
__all__ = __all_mixins + __all_columns