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:
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.