WTForms with images in data-storage services
flask python sqlalchemy wtformsIn this article we will extend WTForms to create form with images stored in a data-storage service, such as AWS S3, GCP Storage or Azure Storage.
Prerequisites
In this example we will use Flask
,
Flask-SQLAlchemy
and Flask-WTF
, but the same
thing can be done using other web frameworks and ORMs. We will need
pillow
as well in case we want to require images with
specific width, height or aspect ratio, and a library to upload images
to the storage service (e.g., boto3
for S3).
Define the models
For this example, create the tables images
and
monkeys
:
class Image(Model):
= 'images'
__tablename__
id = db.Column(db.Integer, primary_key=True)
= db.Column(db.String(256), nullable=False, unique=True)
url
class Monkey(Model):
= 'monkeys'
__tablename__
id = db.Column(db.Integer, primary_key=True)
= db.Column(db.String(80), nullable=False)
name = db.Column(db.Integer, db.ForeignKey('images.id'))
image_id
= db.relationship('Image',
image =True,
single_parent='all,delete-orphan') cascade
Extend WTForms objects
Create an image input and a validator to restrict the input type to images:
from flask_wtf.file import FileAllowed
from markupsafe import Markup
from werkzeug.datastructures import FileStorage
from wtforms.widgets import FileInput
class ImageInput(FileInput):
def __call__(self, field, **kwargs):
'id', field.id)
kwargs.setdefault('type', self.input_type)
kwargs.setdefault(= getattr(field, 'flags', {})
flags for k in dir(flags):
if k in self.validation_attrs and k not in kwargs:
= getattr(flags, k)
kwargs[k]
= ['image-input']
classes = field.data and not isinstance(field.data, FileStorage)
loaded if loaded:
'loaded')
classes.append(= ' '.join(classes)
classes_str = f'<div class="{classes_str}">'
html += '<input %s>' % self.html_params(name=field.name, **kwargs)
html if loaded:
+= f'<img src="{field.data.url}">'
html else:
+= '<img>'
html += '</div>'
html return Markup(html)
class ImageAllowed(FileAllowed):
def __init__(self, upload_set=('jpg', 'jpeg', 'png'), message=None):
super().__init__(upload_set, message)
= []
mime_types for allowed_type in upload_set:
if allowed_type.startswith('image/'):
= allowed_type
mime_type else:
= f'image/{allowed_type}'
mime_type
mime_types.append(mime_type)self.field_flags = {'accept': ', '.join(mime_types)}
The input renders an HTML input
element with “file”
type, along with an img
and wrapped by a div with class
image-input
:
<div class="image-input">
<input accept="image/jpeg, image/jpg" id="image" name="image" type="file">
<img>
</div>
With a bit of javascript and css we can make this input to display the image once the input changed, so we can see the image we are going to upload:
for (let element of document.querySelectorAll('.image-input input')) {
.onchange = function(ev) {
elementlet div = this.parentNode;
let image = div.querySelector('img');
let reader = new FileReader();
.onload = () => {
reader.src = reader.result;
image.classList.add('loaded');
div;
}.readAsDataURL(ev.target.files[0]);
reader;
} }
.image-input {
display: block;
width: 400px;
height: 400px;
border: solid 1px #dadce0;
position: relative;
overflow: clip;
}.image-input.loaded {
height: auto;
}.image-input input[type="file"] {
height: 100%;
width: 100%;
opacity: 0;
position: absolute;
top: 0;
left: 0;
z-index: 10;
cursor: pointer;
}.image-input img {
width: 100%;
display: block;
}
In case we want to add restrictions of image size and aspect ratio we can create another validator:
from PIL import Image
from werkzeug.datastructures import FileStorage
from wtforms.validators import ValidationError
class ImageSize:
def __init__(self,
=None,
width=0,
width_tolerance=None,
height=0,
height_tolerance=None,
aspect_ratio=.01):
aspect_ratio_toleranceassert width or height or aspect_ratio
self.width = width
self.width_tolerance = abs(width_tolerance)
self.height = height
self.height_tolerance = abs(height_tolerance)
self.aspect_ratio = aspect_ratio
self.aspect_ratio_tolerance = abs(aspect_ratio_tolerance)
def __call__(self, form, field):
if field.data and isinstance(field.data, FileStorage):
= Image.open(field.data)
image 0)
field.data.seek(if self.width:
if not self._is_close(self.width,
image.width,self.width_tolerance):
raise ValidationError('Invalid width')
if self.height:
if not self._is_close(self.height,
image.height,self.height_tolerance):
raise ValidationError('Invalid height')
if self.aspect_ratio and not (self.width and self.height):
if not self._is_close(self.aspect_ratio,
/image.height,
image.widthself.aspect_ratio_tolerance):
raise ValidationError('Invalid aspect ratio')
def _is_close(self, a, b, tol):
return abs(a-b) <= tol
Using ImageSize
we can restrict the width to 1000px
(ImageSize(width=1000)
) or allow only squared images
(ImageSize(aspect_ratio=1)
). Note that we add tolerance
variables to allow small errors, specially relevant when calculating
aspect ratios since we need to divide integers and round down.
Then, we create the ImageField
, the field we will use in
our forms, with a default ImageAllowed
validator:
from flask_wtf.file import FileField
class ImageField(FileField):
= ImageInput()
widget
def __init__(self, label=None, validators=None, *args, **kwargs):
if not validators:
= [ImageAllowed()]
validators elif not any(isinstance(i, ImageAllowed) for i in validators):
+= [ImageAllowed()]
validators super().__init__(label, validators, *args, **kwargs)
Using the image field in WTForms
Now we can add an image field to our form. The upload process can be
added to the form in populate_obj
(this method runs after
the form validation) or in the view (after validation, when calling
form.populate_obj
). Here is how to do it in the form:
from werkzeug.datastructures import FileStorage
from wtforms import StringField
from wtforms.validators import DataRequired
from app.models import Image as ImageModel # In case PIL.Image is imported.
from app.utils import upload_image
class MonkeyForm(FlaskForm):
= StringField(validators=[DataRequired()])
name = ImageField(validators=[ImageAllowed(['jpeg', 'jpg']),
image =1920)])
ImageSize(width
def populate_obj(self, obj):
= None
fs if isinstance(self.image.data, FileStorage):
= self.image.data
fs self.image.data = None
super().populate_obj(obj)
if fs:
= upload_image(fs, folder='monkeys')
url = ImageModel(url=url) obj.image
As you can see, a function upload_image
is expected.
This function uploads the FileStorage
variable to the
storage service and return its URL. Here’s an example for S3:
import os
from tempfile import mktemp
import boto3
= os.environ['AWS_ACCESS_KEY_ID']
AWS_ACCESS_KEY_ID = os.environ['AWS_SECRET_ACCESS_KEY']
AWS_SECRET_ACCESS_KEY = os.environ['AWS_DEFAULT_REGION']
AWS_DEFAULT_REGION = os.environ['S3_BUCKET_NAME']
S3_BUCKET_NAME
def upload_image(fs, folder=None):
= mktemp()
path
fs.save(path)= fs.filename
s3_path if folder:
= f'{folder}/{s3_path}'
s3_path = boto3.client('s3',
s3 =AWS_ACCESS_KEY_ID,
aws_access_key_id=AWS_SECRET_ACCESS_KEY,
aws_secret_access_key=AWS_DEFAULT_REGION)
region_name=S3_BUCKET_NAME,
s3.upload_file(Bucket=path,
Filename=s3_path,
Key={'ContentType': fs.mimetype})
ExtraArgsreturn f'https://{S3_BUCKET_NAME}.s3.{AWS_DEFAULT_REGION}' \
f'.amazonaws.com/{s3_path}'