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.

Useful Links