Загрузка файлов на сервер и сохранение в БД

Файл проекта: https://github.com/selfedu-rus/flasksite-17

На этом занятии рассмотрим порядок загрузки файлов на сервер. Когда это бывает необходимо? Например, пользователь в своем профайле хочет сменить аватарку. Для этого он должен выбрать изображение на своем устройстве и загрузить его на сервер. Далее, на стороне сервера происходит обработка принятого файла и связывается с аккаунтом текущего пользователя.

Здесь может возникнуть вопрос: зачем нам создавать отдельный обработчик upload для загрузки файла? Почему бы его не реализовать сразу в profile? Дело в том, что если не разнести эти обработчики, то при обновлении страницы profile автоматически произойдет повторная загрузка этого же изображения, т.к. браузеры при refresh страницы дублируют всю информацию, в том числе и отправку данных формы. Чтобы этого не происходило, как раз и создается отдельный загрузчик, данные из формы направляются к нему, а затем, осуществляется автоматическое перенаправление в профайл. В этом случае обновление profile приведет просто к обновлению страницы без отправки данных формы.

Добавим этот функционал на наш сайт. Загруженную аватарку пользователя будем хранить непосредственно в БД. Это обычная практика в веб-программировании. Поэтому в таблице users создадим специальное поле avatar с типом BLOB:

CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
name text NOT NULL,
email text NOT NULL,
psw text NOT NULL,
avatar BLOB DEFAULT NULL,
time integer NOT NULL
);

И, затем, с помощью известной уже вам функции create_db создадим эту таблицу в БД. Но, предварительно удалим ее (иначе она не будет создана).

Затем, в основном программном модуле добавим специальную константу:

MAX_CONTENT_LENGTH = 1024 * 1024

которая будет ограничивать максимальный размер загружаемого файла, в данном случае 1 Мб.

После этого нам нужно поправить метод addUser в классе FDataBase, чтобы пользователь создавался с аватаром по умолчанию (значение NULL):

    def addUser(self, name, email, hpsw):
…
            self.__cur.execute("INSERT INTO users VALUES(NULL, ?, ?, ?, NULL, ?)", (name, email, hpsw, tm))

Далее, создадим шаблон отображения профайла (profile.html). Он будет следующий:

{% extends 'base.html' %}
 
{% block content %}
{{ super() }}
{% for cat, msg in get_flashed_messages(True) %}
<div class="flash {{cat}}">{{msg}}</div>
{% endfor %}
<table border="0" class="profile-table">
         <tr><td valign="top">
                   <div class="profile-ava"><img src="{{ url_for('userava') }}"></div>
                   <div class="profile-load">
                   <form action="{{url_for('upload')}}" method="POST" enctype="multipart/form-data">
                            <input type="file" name="file">
                            <input type="submit" value="Загрузить">
                   </form> 
                   </div>
         </td>
         <td valign="top" class="profile-panel">
                   <a href="{{url_for('logout')}}">Выйти из профиля</a>
                   <ul class="profile-info">
                   <li>Имя: {{ current_user.getName() }}</li>
                   <li>Email: {{ current_user.getEmail() }}</li>
                   </ul>
         </td></tr>
</table>
{% endblock %}

Мы здесь используем прокси-переменную current_user для обращения к авторизованному пользователю. И через нее вызываем методы класса UserLogin. Добавим этим методы:

    def getName(self):
        return self.__user['name'] if self.__user else "Без имени"
 
    def getEmail(self): 
        return self.__user['email'] if self.__user else "Без email"

Далее, чтобы профиль выглядел более-менее прилично, пропишем следующие стили оформления шаблона profile.html:

.profile-ava {
         width: 130px;
         text-align: center;
         overflow: hidden; 
         background: #eee;
         padding: 10px;
}
 
.profile-ava img {max-width: 150px; height: auto;}
 
.profile-load {
         margin-top: 10px;
         overflow: hidden;
         max-width: 150px;
}
.profile-load input[type=submit], input[type=file] {
         width: 100%;
         font-size: 18px;
}
.profile-load p {
         padding: 0;
         margin: 5px 0 0 0;
}
 
.profile-panel {padding: 0 0 0 10px;}
ul.profile-info {
         list-style: none;
         margin: 10px 0 0 0;
         padding: 0;
         color: #7E652F;
}
ul.profile-info li {margin-top: 10px;}

Отображение аватара

Для отображения аватара создадим отдельный обработчик userava:

@app.route('/userava')
@login_required
def userava():
    img = current_user.getAvatar(app)
    if not img:
        return ""
 
    h = make_response(img)
    h.headers['Content-Type'] = 'image/png'
    return h

Смотрите, мы здесь загружаем аватар пользователя с помощью метода getAvatar, который пропишем в классе UserLogin. Если данные не были получены, то возвращается пустая строка, а иначе, создается объект ответа с параметром 'Content-Type' равным 'image/png', т.к. мы для простоты будем полагать, что все загруженные аватары будут представлены в формате PNG. В результате, при отображении изображения через тег img, в профиле будем видеть соответствующий аватар текущего пользователя.

Теперь перейдем в класс UserLogin и там добавим метод получения аватара:

    def getAvatar(self, app):
        img = None
        if not self.__user['avatar']:
            try:
                with app.open_resource(app.root_path + url_for('static', filename='images/default.png'), "rb") as f:
                    img = f.read()
            except FileNotFoundError as e:
                print("Не найден аватар по умолчанию: "+str(e))
        else:
            img = self.__user['avatar']
 
        return img

Мы здесь вначале проверяем: если в БД аватар не был загружен, то берется изображение по умолчанию:

static/images/default.png

А, иначе, берем аватар из БД. В конце метод возвращает прочитанные данные изображения.

Загрузка аватара

Следующим шагом нам нужно реализовать загрузку изображения пользователя в профайле. Как мы говорили вначале, для этого необходим отдельный обработчик, который можно представить в следующем виде:

@app.route('/upload', methods=["POST", "GET"])
@login_required
def upload():
    if request.method == 'POST':
        file = request.files['file']
        if file andcurrent_user.verifyExt(file.filename):
            try:
                img = file.read()
                res = dbase.updateUserAvatar(img, current_user.get_id())
                if not res:
                    flash("Ошибка обновления аватара", "error")
                    return redirect(url_for('profile'))
                flash("Аватар обновлен", "success")
            except FileNotFoundError as e:
                flash("Ошибка чтения файла", "error")
        else:
            flash("Ошибка обновления аватара", "error")
 
    return redirect(url_for('profile'))

Здесь вначале проверяется, что пришли данные по POST-запросу и берется поле file из объекта request. Это поле ассоциировано с загруженным на сервер файлом, который был выбран через форму в профайле. Далее, идет проверка, что файл был успешно загружен и что его расширение PNG. Затем, происходит чтение данных из файла и обновляется аватар пользователя в БД.

Чтобы этот обработчик работал, нужно добавить два метода. Первый verifyExt в классе UserLogin:

    def verifyExt(self, filename):
        ext = filename.rsplit('.', 1)[1]
        if ext == "png" or ext == "PNG":
            return True
        return False

И второй – updateUserAvatar в классе FDataBase:

    def updateUserAvatar(self, avatar, user_id):
        if not avatar:
            return False
 
        try:
            binary = sqlite3.Binary(avatar)
            self.__cur.execute(f"UPDATE users SET avatar = ? WHERE id = ?", (binary, user_id))
            self.__db.commit()
        except sqlite3.Error as e:
            print("Ошибка обновления аватара в БД: "+str(e))
            return False
        return True

Все, теперь наш функционал полностью готов и пользователи могут загружать свои аватарки в профайл.

Видео по теме