Commit 0ee9e4cb authored by Florian Sachs's avatar Florian Sachs

Multiple Primary keys for sqla backend

Flask-Admin can now handle multiple primary keys to some extent. `get_primary_key()` and `get_pk_value()` where the simple, but important changes. When building the url in `list.html` via `url_for` a url with multiple `&id=x&id=y` is built. As the order as preserved and the request args are from type `werkzeug.datastructures.MultiDict`, the multiple PKs can be grabbed in the correct order an push into `get_one()` which actually has no problems with multiple keys, as sqlalchemy's `query.get()` works exactely in this way.

* `contrib.sqla.tools.get_primary_key()` returns a tuple of all primary keys, if there are more than one PKs
* `contrib.sqla.view.get_pk_value()` returns a tuple with the values of all primary keys, if there are more than one PKs
* `model.helper.get_mdict_item_or_list()` return the single item of a given MultiDict for the given key or the whole list of items, if there are more than one.
* `model.base.BaseModelView.[edit_view|delete_view]` can work with a single *id* or multiple ones, by using `get_mdict_item_or_list()`
* New option `form_excluded_pk_columns_from_unique_validation` for `model.base.BaseModelView` including docstring.
* Option is evaluated in `contrib.sqla.form.AdminModelConverter.()`

Tests and documentation is included. An example file showing the feature is in `sqla/examples/multiplepk.py`

The changes itself are very small, although it took me hours to go there - the MultiDict saved me! I will change my app I am currently working on to multiple primary keys this week and will push updates if necessary.
parent 6ddbfc79
...@@ -103,8 +103,51 @@ you can do something like this:: ...@@ -103,8 +103,51 @@ you can do something like this::
Check :doc:`api/mod_contrib_sqla` documentation for list of Check :doc:`api/mod_contrib_sqla` documentation for list of
configuration properties and methods. configuration properties and methods.
Multiple Primary Keys
---------------------
Models with multiple primary keys have limited support, as a few pitfalls are waiting for you.
With using multiple primary keys, weak entities can be used with Flask-Admin.
Lets Model a car with it's tyres::
class Car(db.Model):
__tablename__ = 'cars'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
desc = db.Column(db.String(50))
def __unicode__(self):
return self.desc
class Tyre(db.Model):
__tablename__ = 'tyres'
car_id = db.Column(db.Integer, db.ForeignKey('cars.id'), primary_key=True)
tyre_id = db.Column(db.Integer, primary_key=True)
car = db.relationship('Car', backref='tyres')
desc = db.Column(db.String(50))
A specific tyre is identified by using the two primary key columns of the ``Tyre`` class, of which the ``car_id`` key
is itself a foreign key to the class ``Car``.
To be able to CRUD the ``Tyre`` class, two steps are necessary, when definig the AdminView::
class TyreAdmin(sqla.ModelView):
form_columns = ['car', 'tyre_id', 'desc']
form_excluded_pk_columns_from_unique_validation = ('car_id', 'tyre_id')
The ``form_columns`` needs to be explizit, as per default only one primary key is displayed. When, like in this
example, one part of the key is a foreign key, do not include the foreign-key-columns here, but the
coresponding relationship.
Per default, all pimary-key-columns are validated for uniquenes. As describe in :doc:`api/mod_model`
the ``form_excluded_pk_columns_from_unique_validation`` attribute can, and must, be used, to exclude *all*
primary-key-columns from this check. Your database will tell you via ``Sqlalchemy``, if the operation
was successfull or not. (Watch out for IntegrityErrors).
A standalone script with the Examples from above can be found in the examples directory.
Example Example
------- -------
Flask-Admin comes with relatively advanced example, which you can Flask-Admin comes with relatively advanced example, which you can
see `here <https://github.com/mrjoes/flask-admin/tree/master/examples/sqla>`_. see `here <https://github.com/mrjoes/flask-admin/tree/master/examples/sqla>`_.
\ No newline at end of file
SQLAlchemy model backend integration example. SQLAlchemy model backend integration examples.
\ No newline at end of file
from flask import Flask
from flask.ext.sqlalchemy import SQLAlchemy
from flask.ext import admin
from flask.ext.admin.contrib import sqla
# Create application
app = Flask(__name__)
# Create dummy secrey key so we can use sessions
app.config['SECRET_KEY'] = '123456790'
# Create in-memory database
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///test.sqlite'
app.config['SQLALCHEMY_ECHO'] = True
db = SQLAlchemy(app)
# Flask views
@app.route('/')
def index():
return '<a href="/admin/">Click me to get to Admin!</a>'
class Car(db.Model):
__tablename__ = 'cars'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
desc = db.Column(db.String(50))
def __unicode__(self):
return self.desc
class Tyre(db.Model):
__tablename__ = 'tyres'
car_id = db.Column(db.Integer, db.ForeignKey('cars.id'), primary_key=True)
tyre_id = db.Column(db.Integer, primary_key=True)
car = db.relationship('Car', backref='tyres')
desc = db.Column(db.String(50))
class CarAdmin(sqla.ModelView):
column_display_pk = True
class TyreAdmin(sqla.ModelView):
column_display_pk = True
form_columns = ['car', 'tyre_id', 'desc']
form_excluded_pk_columns_from_unique_validation = ('car_id', 'tyre_id')
if __name__ == '__main__':
# Create admin
admin = admin.Admin(app, 'Simple Models')
admin.add_view(CarAdmin(Car, db.session))
admin.add_view(TyreAdmin(Tyre, db.session))
# Create DB
db.create_all()
# Start app
app.run(debug=True)
...@@ -166,10 +166,12 @@ class AdminModelConverter(ModelConverterBase): ...@@ -166,10 +166,12 @@ class AdminModelConverter(ModelConverterBase):
if prop.key not in form_columns: if prop.key not in form_columns:
return None return None
kwargs['validators'].append(Unique(self.session, # PK can be explicitely excluded from uniquenes-validation
model, if prop.key not in self.view.form_excluded_pk_columns_from_unique_validation:
column)) kwargs['validators'].append(Unique(self.session,
unique = True model,
column))
unique = True
# If field is unique, validate it # If field is unique, validate it
if column.unique and not unique: if column.unique and not unique:
......
...@@ -11,20 +11,26 @@ def parse_like_term(term): ...@@ -11,20 +11,26 @@ def parse_like_term(term):
def get_primary_key(model): def get_primary_key(model):
""" """
Return primary key name from a model Return primary key name from a model. If the primary key consists of multiple columns,
return the corresponding tuple
:param model: :param model:
Model class Model class
""" """
props = model._sa_class_manager.mapper.iterate_properties props = model._sa_class_manager.mapper.iterate_properties
pks = []
for p in props: for p in props:
if hasattr(p, 'columns'): if hasattr(p, 'columns'):
for c in p.columns: for c in p.columns:
if c.primary_key: if c.primary_key:
return p.key pks.append(c.key)
if len(pks) == 1:
return None return pks[0]
elif len(pks) > 1:
return tuple(pks)
else:
return None
def is_inherited_primary_key(prop): def is_inherited_primary_key(prop):
""" """
......
...@@ -259,6 +259,9 @@ class ModelView(BaseModelView): ...@@ -259,6 +259,9 @@ class ModelView(BaseModelView):
if self.form_choices is None: if self.form_choices is None:
self.form_choices = {} self.form_choices = {}
if self.form_excluded_pk_columns_from_unique_validation is None:
self.form_excluded_pk_columns_from_unique_validation = ()
super(ModelView, self).__init__(model, name, category, endpoint, url) super(ModelView, self).__init__(model, name, category, endpoint, url)
# Primary key # Primary key
...@@ -287,14 +290,22 @@ class ModelView(BaseModelView): ...@@ -287,14 +290,22 @@ class ModelView(BaseModelView):
def scaffold_pk(self): def scaffold_pk(self):
""" """
Return the primary key name from a model Return the primary key name from a model
PK can be a single value or a tuple if multiple PKs exist
""" """
return tools.get_primary_key(self.model) return tools.get_primary_key(self.model)
def get_pk_value(self, model): def get_pk_value(self, model):
""" """
Return the PK value from a model object. Return the PK value from a model object.
PK can be a single value or a tuple if multiple PKs exist
""" """
return getattr(model, self._primary_key) try:
return getattr(model, self._primary_key)
except TypeError:
v = []
for attr in self._primary_key:
v.append(getattr(model, attr))
return tuple(v)
def scaffold_list_columns(self): def scaffold_list_columns(self):
""" """
......
...@@ -14,7 +14,7 @@ from flask.ext.admin.helpers import get_form_data, validate_form_on_submit ...@@ -14,7 +14,7 @@ from flask.ext.admin.helpers import get_form_data, validate_form_on_submit
from flask.ext.admin.tools import rec_getattr from flask.ext.admin.tools import rec_getattr
from flask.ext.admin._backwards import ObsoleteAttr from flask.ext.admin._backwards import ObsoleteAttr
from flask.ext.admin._compat import iteritems, as_unicode from flask.ext.admin._compat import iteritems, as_unicode
from .helpers import prettify_name from .helpers import prettify_name, get_mdict_item_or_list
class BaseModelView(BaseView, ActionsMixin): class BaseModelView(BaseView, ActionsMixin):
...@@ -312,6 +312,22 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -312,6 +312,22 @@ class BaseModelView(BaseView, ActionsMixin):
form_excluded_columns = ('last_name', 'email') form_excluded_columns = ('last_name', 'email')
""" """
form_excluded_pk_columns_from_unique_validation = None
"""
Primary Key Columns, that should explicitely excluded from Validation for uniquenes.
Primary Keys are automatically checked for uniqueness *prior* to the database-save operation.
If your model consists of multiple primary keys, list them here, so the built-in validation
is disabled.
The constraints-check of the database will, of course, still prevent you from saving two identical
primary keys, regardless of how many columns are part of your primary key.
For example::
class MyModelView(BaseModelView):
form_excluded_pk_columns_from_unique_validation = ('id', 'secondid')
"""
form_overrides = None form_overrides = None
""" """
Dictionary of form column overrides. Dictionary of form column overrides.
...@@ -1100,7 +1116,7 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1100,7 +1116,7 @@ class BaseModelView(BaseView, ActionsMixin):
if not self.can_edit: if not self.can_edit:
return redirect(return_url) return redirect(return_url)
id = request.args.get('id') id = get_mdict_item_or_list(request.args, 'id')
if id is None: if id is None:
return redirect(return_url) return redirect(return_url)
...@@ -1136,7 +1152,7 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1136,7 +1152,7 @@ class BaseModelView(BaseView, ActionsMixin):
if not self.can_delete: if not self.can_delete:
return redirect(return_url) return redirect(return_url)
id = request.args.get('id') id = get_mdict_item_or_list(request.args, 'id')
if id is None: if id is None:
return redirect(return_url) return redirect(return_url)
......
...@@ -8,3 +8,26 @@ def prettify_name(name): ...@@ -8,3 +8,26 @@ def prettify_name(name):
Name to prettify Name to prettify
""" """
return name.replace('_', ' ').title() return name.replace('_', ' ').title()
def get_mdict_item_or_list(mdict, key):
"""
Return the value for the given key of the multidict.
A werkzeug.datastructures.multidict can have a single
value or a list of items. If there is only one item,
return only this item, else the whole list as a tuple
:param mdict: Multidict to search for the key
:type mdict: werkzeug.datastructures.multidict
:param key: key to look for
:return: the value for the key or None if the Key has not be found
"""
if hasattr(mdict, 'getlist'):
v = mdict.getlist(key)
if len(v) == 1:
return v[0]
elif len(v) == 0:
return None
else:
return tuple(v)
return None
...@@ -435,6 +435,42 @@ def test_non_int_pk(): ...@@ -435,6 +435,42 @@ def test_non_int_pk():
data = rv.data.decode('utf-8') data = rv.data.decode('utf-8')
ok_('test2' in data) ok_('test2' in data)
def test_multiple__pk():
# Test multiple primary keys - mix int and string together
app, db, admin = setup()
class Model(db.Model):
id = db.Column(db.Integer, primary_key=True)
id2 = db.Column(db.String(20), primary_key=True)
test = db.Column(db.String)
db.create_all()
view = CustomModelView(Model, db.session, form_columns=['id', 'id2', 'test'])
admin.add_view(view)
client = app.test_client()
rv = client.get('/admin/modelview/')
eq_(rv.status_code, 200)
rv = client.post('/admin/modelview/new/',
data=dict(id=1, id2='two', test='test3'))
eq_(rv.status_code, 302)
rv = client.get('/admin/modelview/')
eq_(rv.status_code, 200)
data = rv.data.decode('utf-8')
ok_('test3' in data)
rv = client.get('/admin/modelview/edit/?id=1&id=two')
eq_(rv.status_code, 200)
data = rv.data.decode('utf-8')
ok_('test3' in data)
# Correct order is mandatory -> fail here
rv = client.get('/admin/modelview/edit/?id=two&id=1')
eq_(rv.status_code, 302)
def test_form_columns(): def test_form_columns():
app, db, admin = setup() app, db, admin = setup()
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment