Commit 4ec6473a authored by bryhoyt's avatar bryhoyt

Merge pull request #8 from mrjoes/master

Merge from central repo into bryhoyt's fork
parents 831a42c3 42d35970
......@@ -4,3 +4,4 @@ recursive-include flask_admin/static *
recursive-include flask_admin/templates *
recursive-include flask_admin/translations *
recursive-include flask_admin/tests *
recursive-exclude flask_admin *.pyc
Adding new model backend
========================
Adding a new model backend
==========================
If you want to implement new database backend to use with model views, follow steps found in this guideline.
Flask-Admin makes a few assumptions about the database models that it works with. If you want to implement your own
database backend, and still have Flask-Admin's model views work as expected, then you should take note of the following:
There are few assumptions about models:
1. Each model must have one field which acts as a `primary key` to uniquely identify instances of that model.
However, there are no restriction on the data type or the field name of the `primary key` field.
2. Models must make their data accessible as python properties.
1. Model has "primary key" - value which uniquely identifies
one model in a data store. There's no restriction on the
data type or field name.
2. Model has readable python properties
3. It is possible to get list of models (optionally - sorted,
filtered, etc) from data store
4. It is possible to get one model by its primary key
If that is the case, then you can implement your own database backend by extending the `BaseModelView` class,
and implementing the set of scaffolding methods listed below.
Extending BaseModelView
-------------------------
Steps to add new model backend:
1. Create new class and derive it from :class:`~flask.ext.admin.model.BaseModelView`::
Start off by defining a new class, which derives from from :class:`~flask.ext.admin.model.BaseModelView`::
class MyDbModel(BaseModelView):
pass
By default, all model views accept model class and it
will be stored as ``self.model``.
This class inherits BaseModelView's `__init__` method, which accepts a model class as first argument. The model
class is stored as the attribute ``self.model`` so that other methods may access it.
2. Implement following scaffolding methods:
Now, implement the following scaffolding methods for the new class:
- :meth:`~flask.ext.admin.model.BaseModelView.get_pk_value`
1. :meth:`~flask.ext.admin.model.BaseModelView.get_pk_value`
This method will return primary key value from
the model. For example, in SQLAlchemy backend,
it gets primary key from the model using :meth:`~flask.ext.admin.contrib.sqla.ModelView.scaffold_pk`, caches it
and returns actual value from the model when requested.
This method returns a primary key value from
the model instance. In the SQLAlchemy backend, it gets the primary key from the model
using :meth:`~flask.ext.admin.contrib.sqla.ModelView.scaffold_pk`, caches it
and then returns the value from the model whenever requested.
For example::
......@@ -39,11 +37,9 @@ Steps to add new model backend:
def get_pk_value(self, model):
return self.model.id
- :meth:`~flask.ext.admin.model.BaseModelView.scaffold_list_columns`
Returns list of columns to be displayed in a list view.
2. :meth:`~flask.ext.admin.model.BaseModelView.scaffold_list_columns`
For example::
Returns a list of columns to be displayed in a list view. For example::
class MyDbModel(BaseModelView):
def scaffold_list_columns(self):
......@@ -56,20 +52,19 @@ Steps to add new model backend:
return columns
- :meth:`~flask.ext.admin.model.BaseModelView.scaffold_sortable_columns`
3. :meth:`~flask.ext.admin.model.BaseModelView.scaffold_sortable_columns`
Returns dictionary of sortable columns. Key in a dictionary is field name. Value - implementation
specific, value that will be used by you backend implementation to do actual sort operation.
Returns a dictionary of sortable columns. The keys in the dictionary should correspond to the model's
field names. The values should be those variables that will be used for sorting.
For example, in SQLAlchemy backend it is possible to
sort by foreign key. If there's a field `user` and
it is foreign key for a `Users` table which has a name
field, key will be `user` and value will be `Users.name`.
For example, in the SQLAlchemy backend it is possible to sort by a foreign key field. So, if there is a
field named `user`, which is a foreign key for the `Users` table, and the `Users` table also has a name
field, then the key will be `user` and value will be `Users.name`.
If your backend does not support sorting, return
`None` or empty dictionary.
`None` or an empty dictionary.
- :meth:`~flask.ext.admin.model.BaseModelView.init_search`
4. :meth:`~flask.ext.admin.model.BaseModelView.init_search`
Initialize search functionality. If your backend supports
full-text search, do initializations and return `True`.
......@@ -81,64 +76,73 @@ Steps to add new model backend:
it will add a join, etc) and caches this information for
future use.
- :meth:`~flask.ext.admin.model.BaseModelView.is_valid_filter`
5. :meth:`~flask.ext.admin.model.BaseModelView.scaffold_form`
Verify if provided object is a valid filter.
Generate `WTForms` form class from the model.
Each model backend should have its own set of
filter implementations. It is not possible to use
filters from SQLAlchemy models in non-SQLAlchemy backend.
This also means that different backends might have
different set of available filters.
For example::
Filter is a class derived from :class:`~flask.ext.admin.model.filters.BaseFilter` which implements at least two methods:
class MyDbModel(BaseModelView):
def scaffold_form(self):
class MyForm(Form):
pass
1. :meth:`~flask.ext.admin.model.filters.BaseFilter.apply`
2. :meth:`~flask.ext.admin.model.filters.BaseFilter.operation`
# Do something
return MyForm
`apply` method accepts two parameters: `query` object and a value from the client. Here you will add
filtering logic for this filter type.
6. :meth:`~flask.ext.admin.model.BaseModelView.get_list`
Lets take SQLAlchemy model backend as an example.
All SQLAlchemy filters derive from :class:`~flask.ext.admin.contrib.sqla.filters.BaseSQLAFilter` class.
This method should return list of model instances with paging,
sorting, etc applied.
Each filter implements one simple filter SQL operation
(like, not like, greater, etc) and accepts column as
input parameter.
For SQLAlchemy backend it looks like:
Whenever model view wants to apply a filter to a query
object, it will call `apply` method in a filter class
with a query and value. Filter will then apply
real filter operation.
1. If search was enabled and provided search value is not empty,
generate LIKE statements for each field from `self.searchable_columns`
For example::
2. If filter values were passed, call `apply` method
with values::
class MyBaseFilter(BaseFilter):
def __init__(self, column, name, options=None, data_type=None):
super(MyBaseFilter, self).__init__(name, options, data_type)
for flt, value in filters:
query = self._filters[flt].apply(query, value)
self.column = column
3. Execute query to get total number of rows in the
database (count)
class MyEqualFilter(MyBaseFilter):
def apply(self, query, value):
return query.filter(self.column == value)
4. If `sort_column` was passed, will do something like (with some extra FK logic which is omitted in this example)::
def operation(self):
return gettext('equals')
if sort_desc:
query = query.order_by(desc(sort_field))
else:
query = query.order_by(sort_field)
# You can validate values. If value is not valid,
# return `False`, so filter will be ignored.
def validate(self, value):
return True
5. Apply paging
# You can "clean" values before they will be
# passed to the your data access layer
def clean(self, value):
return value
6. Return count, list as a tuple
7. :meth:`~flask.ext.admin.model.BaseModelView.get_one`
Return a model instance by its primary key.
8. :meth:`~flask.ext.admin.model.BaseModelView.create_model`
Create a new instance of the model from the `Form` object.
9. :meth:`~flask.ext.admin.model.BaseModelView.update_model`
Update the model instance with data from the form.
10. :meth:`~flask.ext.admin.model.BaseModelView.delete_model`
- :meth:`~flask.ext.admin.model.BaseModelView.scaffold_filters`
Delete the specified model instance from the data store.
Return list of filter objects for one model field.
11. :meth:`~flask.ext.admin.model.BaseModelView.is_valid_filter`
Verify whether the given object is a valid filter.
12. :meth:`~flask.ext.admin.model.BaseModelView.scaffold_filters`
Return a list of filter objects for one model field.
This method will be called once for each entry in the
`self.column_filters` setting.
......@@ -155,66 +159,58 @@ Steps to add new model backend:
if isinstance(attr, MyDbTextField):
return [MyEqualFilter(name, name)]
- :meth:`~flask.ext.admin.model.BaseModelView.scaffold_form`
Generate `WTForms` form class from the model.
For example::
class MyDbModel(BaseModelView):
def scaffold_form(self):
class MyForm(Form):
pass
Implementing filters
--------------------
# Do something
return MyForm
Each model backend should have its own set of filter implementations. It is not possible to use the
filters from SQLAlchemy models in a non-SQLAlchemy backend.
This also means that different backends might have different set of available filters.
- :meth:`~flask.ext.admin.model.BaseModelView.get_list`
This method should return list of models with paging,
sorting, etc applied.
The filter is a class derived from :class:`~flask.ext.admin.model.filters.BaseFilter` which implements at least two methods:
For SQLAlchemy backend it looks like:
1. If search was enabled and provided search value is not empty,
generate LIKE statements for each field from `self.searchable_columns`
2. If filter values were passed, call `apply` method
with values::
for flt, value in filters:
query = self._filters[flt].apply(query, value)
1. :meth:`~flask.ext.admin.model.filters.BaseFilter.apply`
2. :meth:`~flask.ext.admin.model.filters.BaseFilter.operation`
3. Execute query to get total number of rows in the
database (count)
`apply` method accepts two parameters: `query` object and a value from the client. Here you can add
filtering logic for the filter type.
4. If `sort_column` was passed, will do something like (with some extra FK logic which is omitted in this example)::
Lets take SQLAlchemy model backend as an example:
if sort_desc:
query = query.order_by(desc(sort_field))
else:
query = query.order_by(sort_field)
All SQLAlchemy filters derive from :class:`~flask.ext.admin.contrib.sqla.filters.BaseSQLAFilter` class.
5. Apply paging
Each filter implements one simple filter SQL operation (like, not like, greater, etc) and accepts a column as
input parameter.
6. Return count, list as a tuple
Whenever model view wants to apply a filter to a query
object, it will call `apply` method in a filter class
with a query and value. Filter will then apply
real filter operation.
- :meth:`~flask.ext.admin.model.BaseModelView.get_one`
For example::
Return one model by its primary key.
class MyBaseFilter(BaseFilter):
def __init__(self, column, name, options=None, data_type=None):
super(MyBaseFilter, self).__init__(name, options, data_type)
- :meth:`~flask.ext.admin.model.BaseModelView.create_model`
self.column = column
Create new model from the `Form` object.
class MyEqualFilter(MyBaseFilter):
def apply(self, query, value):
return query.filter(self.column == value)
- :meth:`~flask.ext.admin.model.BaseModelView.update_model`
def operation(self):
return gettext('equals')
Update provided model with the data from the form.
# You can validate values. If value is not valid,
# return `False`, so filter will be ignored.
def validate(self, value):
return True
- :meth:`~flask.ext.admin.model.BaseModelView.delete_model`
# You can "clean" values before they will be
# passed to the your data access layer
def clean(self, value):
return value
Delete provided model from the data store.
Feel free ask questions if you have problem adding new model backend.
Also, it is good idea to take a look on SQLAlchemy model backend to
see how it works in different circumstances.
Feel free ask questions if you have problems adding a new model backend.
Also, if you get stuck, try taking a look at the SQLAlchemy model backend and use it as a reference.
\ No newline at end of file
......@@ -197,8 +197,8 @@ def build_sample_db():
if __name__ == '__main__':
# Build a sample db on the fly, if one does not exist yet.
app_dir = op.realpath(os.path.dirname(__file__))
database_path = op.join(app_dir, app.config['DATABASE_FILE'])
app_dir = os.path.realpath(os.path.dirname(__file__))
database_path = os.path.join(app_dir, app.config['DATABASE_FILE'])
if not os.path.exists(database_path):
build_sample_db()
......
......@@ -31,8 +31,8 @@ class User(db.Model):
username = db.Column(db.String(80), unique=True)
email = db.Column(db.String(120), unique=True)
# Required for administrative interface
def __str__(self):
# Required for administrative interface. For python 3 please use __str__ instead.
def __unicode__(self):
return self.username
......@@ -54,7 +54,7 @@ class Post(db.Model):
tags = db.relationship('Tag', secondary=post_tags_table)
def __str__(self):
def __unicode__(self):
return self.title
......@@ -62,7 +62,7 @@ class Tag(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.Unicode(64))
def __str__(self):
def __unicode__(self):
return self.name
......@@ -75,7 +75,7 @@ class UserInfo(db.Model):
user_id = db.Column(db.Integer(), db.ForeignKey(User.id))
user = db.relationship(User, backref='info')
def __str__(self):
def __unicode__(self):
return '%s - %s' % (self.key, self.value)
......@@ -85,7 +85,7 @@ class Tree(db.Model):
parent_id = db.Column(db.Integer, db.ForeignKey('tree.id'))
parent = db.relationship('Tree', remote_side=[id], backref='children')
def __str__(self):
def __unicode__(self):
return self.name
......
......@@ -32,7 +32,7 @@ if not PY2:
# Various tools
from functools import reduce
from urllib.parse import urljoin
from urllib.parse import urljoin, urlparse
else:
text_type = unicode
string_types = (str, unicode)
......@@ -50,7 +50,7 @@ else:
# Helpers
reduce = __builtins__['reduce'] if isinstance(__builtins__, dict) else __builtins__.reduce
from urlparse import urljoin
from urlparse import urljoin, urlparse
def with_metaclass(meta, *bases):
......
......@@ -155,6 +155,11 @@ class FileAdmin(BaseView, ActionsMixin):
Edit template
"""
upload_form = UploadForm
"""
Upload form class
"""
def __init__(self, base_path, base_url=None,
name=None, category=None, endpoint=None, url=None,
verify_path=True):
......@@ -285,7 +290,7 @@ class FileAdmin(BaseView, ActionsMixin):
"""
file_data.save(path)
def _get_dir_url(self, endpoint, path, **kwargs):
def _get_dir_url(self, endpoint, path=None, **kwargs):
"""
Return prettified URL
......@@ -410,6 +415,17 @@ class FileAdmin(BaseView, ActionsMixin):
"""
pass
def _save_form_files(self, directory, path, form):
filename = op.join(directory,
secure_filename(form.upload.data.filename))
if op.exists(filename):
flash(gettext('File "%(name)s" already exists.', name=filename),
'error')
else:
self.save_file(filename, form.upload.data)
self.on_file_upload(directory, path, filename)
@expose('/')
@expose('/b/<path:path>')
def index(self, path=None):
......@@ -423,7 +439,7 @@ class FileAdmin(BaseView, ActionsMixin):
base_path, directory, path = self._normalize_path(path)
if not self.is_accessible_path(path):
flash(gettext(gettext('Permission denied.')))
flash(gettext('Permission denied.'))
return redirect(self._get_dir_url('.index'))
# Get directory listing
......@@ -486,21 +502,13 @@ class FileAdmin(BaseView, ActionsMixin):
return redirect(self._get_dir_url('.index', path))
if not self.is_accessible_path(path):
flash(gettext(gettext('Permission denied.')))
flash(gettext('Permission denied.'))
return redirect(self._get_dir_url('.index'))
form = UploadForm(self)
form = self.upload_form(self)
if helpers.validate_form_on_submit(form):
filename = op.join(directory,
secure_filename(form.upload.data.filename))
if op.exists(filename):
flash(gettext('File "%(name)s" already exists.', name=filename),
'error')
else:
try:
self.save_file(filename, form.upload.data)
self.on_file_upload(directory, path, filename)
self._save_form_files(directory, path, form)
return redirect(self._get_dir_url('.index', path))
except Exception as ex:
flash(gettext('Failed to save file: %(error)s', error=ex))
......@@ -547,7 +555,7 @@ class FileAdmin(BaseView, ActionsMixin):
return redirect(dir_url)
if not self.is_accessible_path(path):
flash(gettext(gettext('Permission denied.')))
flash(gettext('Permission denied.'))
return redirect(self._get_dir_url('.index'))
form = NameForm(helpers.get_form_data())
......@@ -558,7 +566,7 @@ class FileAdmin(BaseView, ActionsMixin):
self.on_mkdir(directory, form.name.data)
return redirect(dir_url)
except Exception as ex:
flash(gettext('Failed to create directory: %(error)s', ex), 'error')
flash(gettext('Failed to create directory: %(error)s', error=ex), 'error')
return self.render(self.mkdir_template,
form=form,
......@@ -584,7 +592,7 @@ class FileAdmin(BaseView, ActionsMixin):
return redirect(return_url)
if not self.is_accessible_path(path):
flash(gettext(gettext('Permission denied.')))
flash(gettext('Permission denied.'))
return redirect(self._get_dir_url('.index'))
if op.isdir(full_path):
......@@ -627,7 +635,7 @@ class FileAdmin(BaseView, ActionsMixin):
return redirect(return_url)
if not self.is_accessible_path(path):
flash(gettext(gettext('Permission denied.')))
flash(gettext('Permission denied.'))
return redirect(self._get_dir_url('.index'))
if not op.exists(full_path):
......@@ -672,8 +680,8 @@ class FileAdmin(BaseView, ActionsMixin):
base_path, full_path, path = self._normalize_path(path)
if not self.is_accessible_path(path):
flash(gettext(gettext('Permission denied.')))
if not self.is_accessible_path(path) or not self.is_file_editable(path):
flash(gettext('Permission denied.'))
return redirect(self._get_dir_url('.index'))
dir_url = self._get_dir_url('.index', os.path.dirname(path))
......
......@@ -106,9 +106,9 @@ class AdminModelConverter(ModelConverterBase):
kwargs['label'] = self._get_label(prop.key, kwargs)
kwargs['description'] = self._get_description(prop.key, kwargs)
if column.nullable:
if column.nullable or prop.direction.name != 'MANYTOONE':
kwargs['validators'].append(validators.Optional())
elif prop.direction.name != 'MANYTOMANY':
else:
kwargs['validators'].append(validators.InputRequired())
# Contribute model-related parameters
......
......@@ -428,7 +428,10 @@ class ModelView(BaseModelView):
:returns:
``True`` for ``String``, ``Unicode``, ``Text``, ``UnicodeText``
"""
return name in ('String', 'Unicode', 'Text', 'UnicodeText')
if name:
name = name.lower()
return name in ('string', 'unicode', 'text', 'unicodetext')
def scaffold_filters(self, name):
"""
......
......@@ -177,7 +177,8 @@ class FileUploadField(fields.TextField):
return True
return ('.' in filename and
filename.rsplit('.', 1)[1] in self.allowed_extensions)
filename.rsplit('.', 1)[1].lower() in
map(str.lower, self.allowed_extensions))
def pre_validate(self, form):
if (self.data and
......@@ -208,6 +209,8 @@ class FileUploadField(fields.TextField):
filename = self.generate_name(obj, self.data)
filename = self._save_file(self.data, filename)
# update filename of FileStorage to our validated name
self.data.filename = filename
setattr(obj, name, filename)
......@@ -329,7 +332,7 @@ class ImageUploadField(FileUploadField):
"""
# Check if PIL is installed
if Image is None:
raise Exception('PIL library was not found')
raise ImportError('PIL library was not found')
self.max_size = max_size
self.thumbnail_fn = thumbgen or thumbgen_filename
......
......@@ -3,6 +3,9 @@ from jinja2 import contextfunction
from flask import g, request
from wtforms.validators import DataRequired, InputRequired
from flask.ext.admin._compat import urljoin, urlparse
from ._compat import string_types
......@@ -96,3 +99,17 @@ def prettify_class_name(name):
String to split
"""
return sub(r'(?<=.)([A-Z])', r' \1', name)
def is_safe_url(target):
ref_url = urlparse(request.host_url)
test_url = urlparse(urljoin(request.host_url, target))
return (test_url.scheme in ('http', 'https') and
ref_url.netloc == test_url.netloc)
def get_redirect_target(param_name='url'):
target = request.values.get(param_name)
if target and is_safe_url(target):
return target
......@@ -11,7 +11,7 @@ from flask.ext.admin.base import BaseView, expose
from flask.ext.admin.form import BaseForm, FormOpts, rules
from flask.ext.admin.model import filters, typefmt
from flask.ext.admin.actions import ActionsMixin
from flask.ext.admin.helpers import get_form_data, validate_form_on_submit
from flask.ext.admin.helpers import get_form_data, validate_form_on_submit, get_redirect_target
from flask.ext.admin.tools import rec_getattr
from flask.ext.admin._backwards import ObsoleteAttr
from flask.ext.admin._compat import iteritems, as_unicode
......@@ -1121,8 +1121,8 @@ class BaseModelView(BaseView, ActionsMixin):
"""
column_fmt = self.column_formatters.get(name)
if column_fmt is not None:
return column_fmt(self, context, model, name)
value = column_fmt(self, context, model, name)
else:
value = self._get_field_value(model, name)
choices_map = self._column_choices_map.get(name, {})
......@@ -1250,7 +1250,7 @@ class BaseModelView(BaseView, ActionsMixin):
"""
Create model view
"""
return_url = request.args.get('url') or url_for('.index_view')
return_url = get_redirect_target() or url_for('.index_view')
if not self.can_create:
return redirect(return_url)
......@@ -1278,7 +1278,7 @@ class BaseModelView(BaseView, ActionsMixin):
"""
Edit model view
"""
return_url = request.args.get('url') or url_for('.index_view')
return_url = get_redirect_target() or url_for('.index_view')
if not self.can_edit:
return redirect(return_url)
......@@ -1316,7 +1316,7 @@ class BaseModelView(BaseView, ActionsMixin):
"""
Delete model view. Only POST method is allowed.
"""
return_url = request.args.get('url') or url_for('.index_view')
return_url = get_redirect_target() or url_for('.index_view')
# TODO: Use post
if not self.can_delete:
......
......@@ -90,7 +90,7 @@
{% endif %}
</td>
<td>
{{ size }}
{{ size|filesizeformat }}
</td>
{% endif %}
{% endblock %}
......
......@@ -99,6 +99,7 @@
<input type="checkbox" name="rowid" class="action-checkbox" value="{{ get_pk_value(row) }}" title="{{ _gettext('Select record') }}" />
</td>
{% endif %}
{% block list_row_actions_column scoped %}
<td>
{% block list_row_actions scoped %}
{%- if admin_view.can_edit -%}
......@@ -118,6 +119,7 @@
{%- endif -%}
{% endblock %}
</td>
{% endblock %}
{% for c, name in list_columns %}
<td>{{ get_value(row, c) }}</td>
{% endfor %}
......
......@@ -833,3 +833,26 @@ def test_ajax_fk_multi():
ok_(mdl is not None)
ok_(mdl.model1 is not None)
eq_(len(mdl.model1), 1)
def test_safe_redirect():
app, db, admin = setup()
Model1, _ = create_models(db)
db.create_all()
view = CustomModelView(Model1, db.session)
admin.add_view(view)
client = app.test_client()
rv = client.post('/admin/model1view/new/?url=http://localhost/admin/model2view/',
data=dict(test1='test1large', test2='test2'))
eq_(rv.status_code, 302)
eq_(rv.location, 'http://localhost/admin/model2view/')
rv = client.post('/admin/model1view/new/?url=http://google.com/evil/',
data=dict(test1='test1large', test2='test2'))
eq_(rv.status_code, 302)
eq_(rv.location, 'http://localhost/admin/model1view/')
......@@ -95,6 +95,10 @@ def test_image_upload_field():
safe_delete(path, 'test2.png')
safe_delete(path, 'test2_thumb.jpg')
safe_delete(path, 'test1.jpg')
safe_delete(path, 'test1.jpeg')
safe_delete(path, 'test1.gif')
safe_delete(path, 'test1.png')
safe_delete(path, 'test1.tiff')
class TestForm(form.BaseForm):
upload = form.ImageUploadField('Upload',
......@@ -204,6 +208,25 @@ def test_image_upload_field():
ok_(op.exists(op.join(path, 'test1.jpg')))
# check allowed extensions
for extension in ('gif', 'jpg', 'jpeg', 'png', 'tiff'):
filename = 'copyleft.' + extension
filepath = op.join(op.dirname(__file__), 'data', filename)
with open(filepath, 'rb') as fp:
with app.test_request_context(method='POST', data={'upload': (fp, filename)}):
my_form = TestNoResizeForm(helpers.get_form_data())
ok_(my_form.validate())
my_form.populate_obj(dummy)
eq_(dummy.upload, my_form.upload.data.filename)
# check case-sensitivity for extensions
filename = op.join(op.dirname(__file__), 'data', 'copyleft.jpg')
with open(filename, 'rb') as fp:
with app.test_request_context(method='POST', data={'upload': (fp, 'copyleft.JPG')}):
my_form = TestNoResizeForm(helpers.get_form_data())
ok_(my_form.validate())
def test_relative_path():
app = Flask(__name__)
......
......@@ -254,6 +254,10 @@ msgstr "Создать"
msgid "Save and Add"
msgstr "Сохранить и Добавить"
#: ../flask_admin/templates/admin/model/edit.html:6
msgid "Save and Continue"
msgstr "Сохранить и Продолжить"
#: ../flask_admin/templates/admin/model/inline_form_list.html:24
msgid "Delete?"
msgstr "Удалить?"
......
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