Flask-Admin is a Flask extension that solves the boring problem of building an admin interface on top of an existing data model. With little effort, it lets you manage the data of your web application through a user-friendly interface. To make your data a little more interesting it is often useful to provide some images that can be integrated in slideshows, articles or even as an exchangeable logo of your application. While image uploads are indeed a very common task for web applications I found myself pondering frequently on how to realize a consistent integration in the Flask-Admin modelview system. Especially I wanted a solution that allowed me to upload images regardless of their filename and also handle duplicates graciously without breaking the association of my database entry to the referenced imagefile. So this article is about how to achieve consistent image file storage implemented in Flask-Admin by using the Flask-Uploads extension to automatically generate UUIDs as filenames and link them to a database entry.
The basic Flask application is pretty straight forward. First we create a requirements.txt file that contains our needed Python packages:
requirements.txt
flask
flask-uploads
flask-admin
flask-sqlalchemy
Than we create our app.py file containing the basic Flask application with an
administrator view that can be accessed by using the /admin endpoint.
app.py
import os
from flask import Flask
from flask_admin import Admin
from flask_admin.contrib.sqla import ModelView
from flask_sqlalchemy import SQLAlchemy
app = Flask(__name__)
app.config['SECRET_KEY'] = 'my secret'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///data.sqlite'
admin = Admin(app, template_mode='bootstrap3')
db = SQLAlchemy(app)
class Image(db.Model):
__tablename__ = 'images'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64), unique=True, nullable=False)
filename = db.Column(db.String(128), unique=True)
def __repr__(self):
return self.name
db.create_all()
admin.add_view(ModelView(Image, db.session, name='Images'))
if __name__ == '__main__':
app.run()
We can run this application by calling $ python app.py. Since Flask 0.11 we can also
use the powerful new command line interface (cli)
based on click to run the application:
$ export FLASK_APP=app.py
$ flask run
* Serving Flask app "app"
* Forcing debug mode on
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
* Restarting with stat
If we visit http://localhost:5000/admin we will see our Flask-Admin CRUD frontend with a menu item called images that let's us create, delete and edit images within our database. Each image we create needs to be given a mandatory and unique name. Additionally to the given name we are also asked for a filename pointing to our actual image file. So if we upload an image manually to our server we can place the path pointing to that file in this field. But that is not the way we want it. We want our web application to take complete care of the image upload and automatically link the uploaded file to the database entry. We will use the Flask-Uploads extension here. It allows the application to flexibly and efficiently handle file uploading and serving the uploaded file. We need to add the following lines to our application:
app.py
from flask_uploads import UploadSet, IMAGES, configure_uploads, \
patch_request_class
app.config['UPLOADED_IMAGES_DEST'] = 'static/images'
app.config['UPLOADED_IMAGES_URL'] = '/static/images/'
images = UploadSet('images', IMAGES)
configure_uploads(app, (images))
patch_request_class(app, 16 * 1024 * 1024)
This creates a new UploadSet for predefined image file
types and stores the
configuration in the app. Via the
patch_request_class()
function we allow file uploads of a file size up to 16MiB. The images will be stored in
static/images and the relative URL will be /static/images/. With Flask-Uploads it
is possible to define multiple upload sets for different filetypes and store them each
in a different directory. Also we need to implement two properties in our Image model
to access the filepath and url:
app.py
class Image(db.Model):
# ...
@property
def url(self):
return images.url(self.filename)
@property
def filepath(self):
if self.filename is None:
return
return images.path(self.filename)
The next step is to integrate the file upload in our Flask-Admin CRUD frontend. To
achieve this we will need a customized
ModelView
for our image items. So we will subclass ModelView and name it ImageView. In the
list view we want to see not only the name and filename but also a thumbnail of the
image itself. This is done by adding an image column to the
column_list
and adding a formatter named _list_thumbnail()to the
column_formatters
dictionary. Also we replace the original TextField for the filename by an
ImageUploadField
that let's us open a dialog to choose a file for uploading.
app.py
def _list_thumbnail(view, context, model, name):
if not model.filename:
return ''
return Markup(
'<img src="{model.url}" style="width: 150px;">'.format(model=model)
)
class ImageView(ModelView):
column_list = [
'image', 'name', 'filename', 'size'
]
column_formatters = {
'image': _list_thumbnail
}
form_extra_fields = {
'filename': form.ImageUploadField(
'Image',
base_path=imagedir,
url_relative_path='images/',
)
}
Now we are able to upload images. But we also might want to delete them if we don't need
them anymore. We can do this easily with the Flask-Admin CRUD frontend by using the
small delete button in the list view. But at the moment this will only delete the
database entry and not the image itself. To actually delete the image file we need to
place a database
hook
that gets triggered after an database object has been deleted.
app.py
from sqlalchemy import event
@event.listens_for(Image, 'after_delete')
def del_image(mapper, connection, target):
if target.filepath is not None:
try:
os.remove(target.filepath)
except OSError:
pass
Now we can upload images, delete them and even change images in our Flask-Admin
environment. The only drawback comes if there are multiple images with the same name.
Actually this can not happen as we defined our Image.name field to be unique. However
it is possible that the filenames of uploaded images are the same. E.g. if we have
uploaded an image foo.png and want to upload a second image foo.png from a different
directory then we are not able to do so as it is not allowed to upload images with the
same filename. This is a bit of a drawback since we actually don't care about the real
filename of a stored image and use the name property for identification. This is why
we actually can drop the original filename all together and use UUIDs to store the
images. So we create a new function that generates filenames with a
UUID1 and the original
extension.
app.py
import uuid
from werkzeug.utils import secure_filename
def _imagename_uuid1_gen(obj, file_data):
_, ext = os.path.splitext(file_data.filename)
uid = uuid.uuid1()
return secure_filename('{}{}'.format(uid, ext))
We use this function as our name generator by adding it to the name_gen property of
our ImageView.form_extra_fields dictionary:
app.py
class ImageView(ModelView):
# ...
form_extra_fields = {
'filename': form.ImageUploadField(
'Image',
base_path=imagedir,
url_relative_path='images/',
namegen=_imagename_uuid1_gen,
)
}
uuid.uuid1() generates a
UUID from the host Id, a sequence number and the current time. This way each uuid is
meant to be unique.
Now our application allows us to upload image files regardless of their filename and keep a consistent link between our database entries and the actual files stored on the server. Here you can see the whole application in its final state:
app.py
import os
from flask import Flask, Markup
from flask_admin import Admin, form
from flask_admin.contrib.sqla import ModelView
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_uploads import UploadSet, IMAGES, configure_uploads, \
patch_request_class
from sqlalchemy import event
import uuid
from werkzeug.utils import secure_filename
app = Flask(__name__)
app.config['SECRET_KEY'] = 'my secret'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///data.sqlite'
app.config['UPLOADED_IMAGES_DEST'] = imagedir = 'static/images'
app.config['UPLOADED_IMAGES_URL'] = '/static/images/'
admin = Admin(app, template_mode='bootstrap3')
db = SQLAlchemy(app)
migrate = Migrate(app, db)
images = UploadSet('images', IMAGES)
configure_uploads(app, (images))
patch_request_class(app, 16 * 1024 * 1024)
@app.route('/')
def index():
return 'Hello World'
class Image(db.Model):
__tablename__ = 'images'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64), unique=True, nullable=False)
filename = db.Column(db.String(128), unique=True)
def __repr__(self):
return self.name
@property
def url(self):
return images.url(self.filename)
@property
def filepath(self):
if self.filename is None:
return
return images.path(self.filename)
@event.listens_for(Image, 'after_delete')
def del_image(mapper, connection, target):
if target.filepath is not None:
try:
os.remove(target.filepath)
except OSError:
pass
def _list_thumbnail(view, context, model, name):
if not model.filename:
return ''
return Markup(
'<img src="{model.url}" style="width: 150px;">'.format(model=model)
)
def _imagename_uuid1_gen(obj, file_data):
_, ext = os.path.splitext(file_data.filename)
uid = uuid.uuid1()
return secure_filename('{}{}'.format(uid, ext))
class ImageView(ModelView):
column_list = [
'image', 'name', 'filename',
]
column_formatters = {
'image': _list_thumbnail
}
form_extra_fields = {
'filename': form.ImageUploadField(
'Image',
base_path=imagedir,
url_relative_path='images/',
namegen=_imagename_uuid1_gen,
)
}
I hope you enjoyed this tutorial and if you found anything not right or want to leave some comments don't hesitate to do so.