Spis treści
Autor: Łukasz Herok, lukasz@lukaszherok.com
Celem warsztatów będzie zrealizowanie przykładowej aplikacji webowej w technologii Python - Flask przy użyciu narzędzi do prowadzenia projektu informatycznego GitLab, Git.
Aplikacja ma służyć do zapisywania publikacji przez pracowników wydawnictwa z możliwością dołączania plików multimedialnych.
Utworzenie konta użytkownika na GitLab https://gitlab.com/.
Python
Pobranie aktualnej wersji z https://python.org i instalacja na stacji.
Pycharm
Pobrać i zainstalować https://www.jetbrains.com/pycharm/
VCS > Clone...
jeśli pojawi się ostrzeżenie że nie ma git to kliknąć je, aby doinstalował. (Instalacja z poza Pycharm: https://git-scm.com/downloads)
W GitLab skopiować URL projektu Clone with HTTPS,
i podać go w Pycharm
Stworzyć venv
- (wydzielone środowisko python na cele projektu) https://docs.python.org/3/library/venv.html
File > Settings
w szukaczu wpisać inter
, wybrać gałąź Python interpreter
> Add
Doinstalować pakiet Flask wybierając +
w menu listy Pakietów.
Założenie venv
i instalacja Flask z linii komend:
$ python -m venv venv
$ pip install Flask
GitLab > Issues > Boards
Zdefiniowanie tablic kanban:
aby dodać tablice WIP i Test najpierw należy zdefiniować etykiety, którymi będą oznaczane zadania, które mają się pojawić na tablicy: Project Information > Labels
Dodanie zadań:
W pliku .gitignore podajemy pliki i ścieżki do pominięcia przez repozytorium git.
Przesuwamy zadanie "Init project" do WIP.
https://git-scm.com/docs/gitignore
Założenie pliku i wpisanie
__pycache__
*.swp
venv
.idea
Commit i push z Pycharm do mastera.
Zamykamy zadanie.
Przesuwamy zadanie do WIP.
app/__init__.py
from flask import Flask
app = Flask(__name__)
from app import routes
app/routes.py
from app import app
@app.route('/')
@app.route('/index')
def index():
return "Hello, World!"
Uruchomienie z linii komend: flask run
https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-i-hello-world
Commit "Hello world"
$ git checkout -b hello
$ git add app/__init__.py
$ git add app/routes.py
$ git commit -m "Test Hello world"
$ git branch
Branch zostaje lokalnie (nie widać go w GitLab) zostanie później usunięty, nie będziemy z niego korzystać.
Stworzenie gałęzi devel
https://datasift.github.io/gitflow/IntroducingGitFlow.html
The master branch tracks released code only. The only commits to master are merges from release branches and hotfix branches.
reqirements.txt
Wygenerowanie pliku z informacją o pakietach, które należy doinstalować do projektu. Zapis bezpośrednio w gałęzi devel. (Zgodnie z GitFlow zaleca się tworzenie brancha + merge).
$ git checkout devel
$ pip freeze > requirements.txt
$ git status
$ git add requirements.txt
$ git commit -m "Flask's packages"
$ git push
Sprawdzenie na GitLab (Repository > Branches, Graph, Compare)
Zapoznanie się z zasadami pracy z gałęzami w Git:
https://git-scm.com/book/be/v2/Git-Branching-Branch-Management
https://git-scm.com/book/en/v2/Git-Branching-Basic-Branching-and-Merging
Stworzenie pliku przechowującego konfigurację projektu.
Utworzenie gałęzi dla configa.
git checkout -b config
lub w Pycharm
git status
git branch
Domyślnych plik z konfiguracją config_default.py
import os
basedir = os.path.abspath(os.path.dirname(__file__))
class ConfigDefault(object):
SECRET_KEY = os.environ.get('SECRET_KEY') or 'fdaga()ff===h32klnfdskjndakjf)_+OJLKNh'
Dodajemy plik config.py
- nie dodajemy go do repozytorium.
from config_default import ConfigDefault
class Config(ConfigDefault):
pass
Dopisanie config.py
do .gitignore
W pliku config.py przeciążyć SECRET_KEY.
app/__init__.py
https://flask.palletsprojects.com/en/2.0.x/patterns/appfactories/
Przygotowanie aplikacji na rozwój (wzorzec fabryka)
from flask import Flask
def create_app(config_class):
app = Flask(__name__)
app.config.from_object(config_class)
@app.route('/')
@app.route('/index')
def index():
return "Hello, World!"
return app
Plik tworzący aplikację flask dla serwera developerskiego
myapp.py
from app import create_app
from config import Config
app = create_app(Config)
Uruchomienie aplikacji
set FLASK_ENV=development
set FLASK_DEBUG=True
set FLASK_APP=erp_b2b_flask.py
venv\Scripts\flask run
Stworzenie konfiguracji w Pycharm > Run > Edit configuration
Module: flask
Parameters: run
Environment variables: PYTHONUNBUFFERED=1;FLASK_DEBUG=True;FLASK_ENV=development;FLASK_APP=myapp.py
Commit i merge do devel.
Przesunięcie Issue na tablicę Test.
Stworzyć nową gałąź git o nazwie database
z gałęzi devel.
Instalacja pakietów do komunikacji z bazą danych i zarządzania zmianą:
$ pip install flask-sqlalchemy
$ pip install flask-migrate
Włączyć zainstalowane moduły w aplikacji Flask:
app/__init__.py
from flask import Flask
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
migrate = Migrate()
def create_app(config_class):
app = Flask(__name__)
app.config.from_object(config_class)
db.init_app(app)
migrate.init_app(app, db)
@app.route('/')
@app.route('/index')
def index():
return "Hello, World!"
return app
from app import models
Skonfigurować połączenie z bazą danych.
config_default.py
import os
basedir = os.path.abspath(os.path.dirname(__file__))
class ConfigDefault(object):
SECRET_KEY = os.environ.get('SECRET_KEY') or 'fdaflkaj()Hih87**skjndakjf)_+OJLKNh'
SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'app.db')
SQLALCHEMY_TRACK_MODIFICATIONS = False
Zdefniowanie klasy użytkowników:
app/models.py
from app import db
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), index=True, unique=True)
email = db.Column(db.String(120), index=True, unique=True, nullable=False)
password_hash = db.Column(db.String(128))
def __repr__(self):
return '<User {}>'.format(self.username)
Przetestowanie możliwości tworzenia nowych użytkowników. Pycharm > Python console
>>> from app.models import User
>>> u = User(username='lukasz')
>>> u
<User lukasz>
Utworzenie tablicy Users:
$env:FLASK_APP = "myapp"
set FLASK_APP=myapp.py
export FLASK_APP=myapp.py
flask db init
flask db migrate -m "Users"
flask db upgrade
Zapis do bazy danych
>>> from app import create_app
>>> from config import Config
>>> from app import db
>>> app = create_app(Config)
>>> app.app_context().push()
>>> db
<SQLAlchemy engine=sqlite:////home/user/workspace/myapp-saas/app.db>
>>> from app.models import User
>>> u = User(username='Wojciech')
>>> db.session.add(u)
>>> db.session.commit()
Sprawdzić w kliencie do bazy danych Sqlite, że dane się zapisały.
Zapisanie wymagań dla nowych pakietów do requirements.txt
.
Przygotowanie do commita:
(venv) [lukasz@rademenes myapp-saas]$ git status
Na gałęzi 02-database
Zmiany do złożenia:
(użyj „git restore --staged <plik>...”, aby wycofać)
zmieniono: app/__init__.py
nowy plik: app/models.py
zmieniono: config_defalut.py
nowy plik: migrations/README
nowy plik: migrations/alembic.ini
nowy plik: migrations/env.py
nowy plik: migrations/script.py.mako
nowy plik: migrations/versions/970540a64c0e_users.py
Wysłanie gałęzi do repozytorium zdalnego (git push --set-upstream origin database
).
https://gitlab.com/myapp/saas/-/tree/02-database
https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-iv-database
Instalacja gotowego pakietu do zarządzania użytkownikami (rejestracja, logowanie, zmiana hasła, potwierdzenia mailowe)
pip install Flask-User email_validator
pip freeze > requirements.txt
Rozszerzenie tabeli użytkowników o aktywność i e-mail:
from flask_user import UserMixin
from app import db
class User(db.Model, UserMixin):
id = db.Column(db.Integer, primary_key=True)
active = db.Column('is_active', db.Boolean(), nullable=False, server_default='1')
username = db.Column(db.String(64), index=True, unique=True)
email = db.Column(db.String(255, collation='NOCASE'), nullable=False, unique=True)
email_confirmed_at = db.Column(db.DateTime())
password = db.Column(db.String(255), nullable=False, server_default='')
def __repr__(self):
return '<User {}>'.format(self.username)
Migracja i aktualizacja schematu bazy danych:
flask db migrate -m "Users - support for Flask-User"
flask db upgrade
Włączenie dodatku i aktualizacja głównej strony:
# app/__init__.py
from flask import Flask, url_for
from flask_login import current_user
from flask_user import UserManager
# ....
def create_app(config_class):
# ...
# Setup Flask-User and specify the User data-model
from app.models import User
user_manager = UserManager(app, db, User)
@app.route('/')
@app.route('/index')
def index():
if current_user.is_authenticated:
return current_user.username + "<p><a href= " + url_for('user.logout') + ">Sign out</a></p>"
else:
return "<p><a href= " + url_for('user.login') + ">Sign in</a></p>"
Dodanie konfiguracji domyślnej:
# config_defalut.py
class ConfigDefault(object):
# ...
# Flask-User settings
USER_APP_NAME = "myapp" # Shown in and email templates and page footers
USER_ENABLE_EMAIL = True # Enable email authentication
USER_ENABLE_USERNAME = True # Disable username authentication
USER_REQUIRE_RETYPE_PASSWORD = False
USER_EMAIL_SENDER_NAME = USER_APP_NAME
USER_EMAIL_SENDER_EMAIL = "noreply@example.com"
# Flask-Mail SMTP server settings
# https://myaccount.google.com/lesssecureapps allow
MAIL_SERVER = 'smtp.gmail.com'
MAIL_PORT = 465
MAIL_USE_SSL = True
MAIL_USE_TLS = False
MAIL_USERNAME = 'email@example.com'
MAIL_PASSWORD = 'password'
MAIL_DEFAULT_SENDER = '"MyApp" <noreply@example.com>'
Podanie konfiguracji właściwej w pliku nie składowanym w repozytorium:
# config.py
from config_defalut import ConfigDefault
class Config(ConfigDefault):
# Flask-Mail SMTP server settings
MAIL_SERVER = 'smtp.gmail.com'
MAIL_PORT = 465
MAIL_USE_SSL = True
MAIL_USE_TLS = False
MAIL_USERNAME = 'myapp@gmail.com'
MAIL_PASSWORD = 'password'
MAIL_DEFAULT_SENDER = 'myapp'
W tym kroku chcemy standaryzować wygląd naszej aplikacji wg frameworku Bootstrap i dostosować do niego moduł User-Flask
Podłączenie bootstrap
Pliki css Bootstrap można podłączyć podając zewnętrzne linki lub ściągając paczkę do projektu. Poniżej przykład z pobraniem źródeł.
W pierwszej kolejności należy pobrać Bootstrap: https://getbootstrap.com/, pobieramy Compiled CSS and JS i Examples.
Zakładamy katalogi i umieszczamy w nich pliki ze ściągniętej paczki:
app/
static/
css/
bootstrap.min.css
js/
bootstrap.bundle.min.js
bootstrap.bundle.min.js.map
Zakładamy plik app\templates\base.html
i wkopiujemy do niego zawartość bootstrap-5.0.2-examples/dashboard/index.html
oraz umieszczamy plik dashboard.css
w static/css
. Następnie dokonujemy przeglądu zawartości pliku i wprowadzamy niezbędne poprawki, między innymi:
<head>
<link href="/static/css/bootstrap.min.css" rel="stylesheet">
Jinja templates
https://jinja.palletsprojects.com/en/2.10.x/templates/
Dodajemy templates\index.html
{% extends "base.html" %}
{% block head %}
{% endblock %}
{% block content %}
<div class="row text-center p-5">
<h2>{{ _('Welcome to myapp!') }}</h2>
</div>
{% endblock %}
i uzupełniamy base.html
o bloki head
,content
, flash_messages
<head>
...
{% block head %}
{% endblock %}
</head>
<div class="container-fluid">
<div class="row">
<nav id="sidebarMenu" class="col-md-3 col-lg-2 d-md-block bg-light sidebar collapse">
....
</nav>
{% block main %}
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
{% block flash_messages %}
{%- with messages = get_flashed_messages(with_categories=true) -%}
{% if messages %}
{% for category, message in messages %}
{% if category=='error' %}
{% set category='danger' %}
{% endif %}
<div class="alert alert-{{ category }}">{{ message|safe }}</div>
{% endfor %}
{% endif %}
{%- endwith %}
{% endblock %}
{% block content %}
{% endblock %}
</main>
{% endblock %}
</div>
</div>
Poprawiamy routing w init.py
:
@app.route('/index')
def index():
return render_template('index.html')
Uruchamiamy program i sprawdzamy efekt pracy w przeglądarce.
Dostosowanie szablonów Flask-user
https://flask-user.readthedocs.io/en/latest/customizing_forms.html
Zgodnie z powyższą instrukcją wkopiujemy szablony do app/templates/flask-user
.
Otwieramy login.html i śledzimy wstecznie ścieżkę rozszerzeń {% extends %}
. Dochodząc do końca modyfikujemy szablon comonbase.html
, będący początkiem wszystkich innych szablonów:
{% extends "base.html" %}
{% block head %}
<style>
.form-flask_user {
width: 100%;
max-width: 330px;
padding: 15px;
margin: auto;
}
</style>
{% endblock %}
{% block main %}
<div class="form-flask_user">
{% block content %}
{% endblock %}
</div>
{% endblock %}
Uruchamiamy program i sprawdzamy efekt pracy w przeglądarce. Sprawdzamy, poprawiamy i dostosowujemy pozostałe widoki według własnych preferencji.
Poprawiamy dodatkowo linki w base.html
służące do zalogowania się do serwisu. Dodatkowo możemy różnicować wygląd dla użytkownika zalogowanego i niezalogowanego, poprzez sprawdzenie current_user.is_anonymous
, np.:
{% if current_user.is_anonymous %}
<p>
<a href=" {{ url_for('user.login') }} "> {{ _('Sign in') }}</a>
</p>
{% endif %}
Zadania dodatkowe:
Przy rejestaracji użytkownika należy od razu założyć mu jego domyślną - osobistą firmę. W tym celu musimy rozszerzyć bazę danych o nową tablę i podłączyć się pod proces rejestracji użytkownika.
https://docs.sqlalchemy.org/en/14/orm/basic_relationships.html#many-to-many
models.py
from flask_user import UserMixin
from app import db
class User(db.Model, UserMixin):
__tablename__ = 'user'
#...
member_table = db.Table('member', db.Model.metadata,
db.Column('user_id', db.ForeignKey('user.id'), primary_key=True),
db.Column('company_id', db.ForeignKey('company.id'), primary_key=True)
)
class Company(db.Model):
__tablename__ = 'company'
id = db.Column(db.Integer, primary_key=True)
active = db.Column('is_active', db.Boolean(), nullable=False, server_default='1')
name = db.Column(db.String(200), index=True, unique=True)
vat_id = db.Column(db.String(50), index=True, unique=True)
email = db.Column(db.String(255, collation='NOCASE'), unique=True)
members = db.relationship("User",
secondary=member_table
)
def __repr__(self):
return '<Company {}>'.format(self.name)
$env:FLASK_APP = "myapp.py"
flask db migrate -m "company"
flask db upgrade
Jeśli będą problemy z wygenerowaniem migracji to zainicjować bazę od nowa skasować app.db
i migrations/versionos/*.py
Sprawdzenie działania w konsoli Python:
from myapp import app
app.app_context().push()
from app.models import User, Company
u = User(username='Wojtek', email='luk@sszz.pl')
c = Company(name="Wojtek co")
c.members.append(u)
from app import db
db.session.add(c)
db.session.commit()
Sprawdzić w kliencie bazy danych, że nowe dane się pojawiły.
Relacja od strony użytkownika
Aby użytkownik mógł również mieć bezpośredni dostęp do swoich firm przez ORM, należy poprawić realacje poprzez uzupełnianie klasy Users lub dopisanie backref do Company.
class Company(db.Model):
# ....
members = db.relationship("User",
secondary=member_table,
backref="companies"
)
Testujemy działanie:
from myapp import app
app.app_context().push()
from app.models import User, Company
c2 = Company(name="Firma 2")
u2 = User(username='Karol', email='karol@o2.pl')
u2.companies.append(c2)
from app import db
db.session.add(u2)
db.session.commit()
Automatyczne zakładanie firmy dla każdego użytkownika
Korzystając z sygnałów Flask:
https://flask-user.readthedocs.io/en/latest/recipes.html?highlight=recipes#after-registration-hook
dodajemy w app/__init__.py
:
def create_app(config_class):
# ...
# User register hook
from flask_user.signals import user_registered
@user_registered.connect_via(app)
def _after_registration_hook(sender, user, **extra):
sender.logger.info('user registered')
from app.models import Company
c = Company()
user.companies.append(c)
db.session.add(c)
db.session.commit()
return app
Użytkownik (w ramach firmy), może dodawać swoje publikacje.
Stworzymy moduł Flask (blueprint), który będzie konsolidował funkcjonalność dla publikacji.
In Flask, a blueprint is a logical structure that represents a subset of the application. A blueprint can include elements such as routes, view functions, forms, templates and static files. If you write your blueprint in a separate Python package, then you have a component that encapsulates the elements related to specific feature of the application.
https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-xv-a-better-application-structure
Więcej informacji o Blueprintach: https://flask.palletsprojects.com/en/2.0.x/blueprints/
Dodajemy blueprint pub
(New > Python package)
Uzupełniamy app/pub/__init__.py
from flask import Blueprint
bp = Blueprint('pub', __name__, template_folder='templates')
Dodajemy routing app/pub/routes.py
from flask import render_template
from flask_user import login_required
from app.pub import bp
@login_required
@bp.route('/list')
def list_():
return render_template('list.html')
oraz template pub/templates/list.html
{% extends "base.html" %}
{% block head %}
{% endblock %}
{% block content %}
<h1>Lista publikacji</h1>
{% endblock %}
Rejestrujemy w app/__init__.py
def create_app(config_class):
# ....
# Setup Flask-User and specify the User data-model
from app.models import User
user_manager = UserManager(app, db, User)
from app.pub import bp as pub_bp
app.register_blueprint(pub_bp, url_prefix='/pub')
Podpinamy w menu base.html
:
<a class="nav-link active" aria-current="page" href="{{ url_for('pub.list_') }}">
<span data-feather="home"></span>
Publikacje
</a>
Dodawanie publikacji
Zdefiniować model
class User(db.Model)
# ...
def current_company(self):
return self.companies[0] # So far as we haven't implemented multiple companies yet
class Company(db.Model):
# ...
publications = relationship("Publication", backref='publication')
class Publication(db.Model):
__tablname__ = 'publication'
id = Column(Integer, primary_key=True)
active = Column('is_active', Boolean(), nullable=False, server_default='1')
title = Column(String(300), index=True, unique=True)
author = Column(String(300), index=True, unique=True)
isbn = Column(String(50), index=True, unique=True)
id_company = Column(Integer, ForeignKey('company.id'))
Wygnerować migrację i zaktualizować schemat bazy danych.
Dodać próbnie dane w konsoli Pythona.
from myapp import app
app.app_context().push()
from app.models import Company, Publication
c = Company.query.first()
c
<Company Wojtek co>
p = Publication(title='Elementarz')
p.author = 'M. Falski'
p.company = c
from app import db
db.session.add(p)
db.session.commit()
Sprawdzić w bazie danych zapis.
Formularz dodawania publikacji
pub/templates/ed.html
{% extends "base.html" %}
{% block head %}
{% endblock %}
{% block content %}
<h1>Publikacja</h1>
<div class="container py-5">
<form action="" method="post">
<div class="form-group">
{{ form.hidden_tag() }}
{{ form.title.label }}
{{ form.title(class="form-control") }}
{{ form.author.label }}
{{ form.author(class='form-control') }}
{{ form.isbn.label }}
{{ form.isbn(class='form-control') }}
</div>
{% for field, error in form.errors.items() %}
<span style="color: red;">{{ field }} {{ error }}</span>
{% endfor %}
{{ form.submit(class='form-control btn btn-success') }}
</form>
</div>
{% endblock %}
pub/forms.py
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired
class EdForm(FlaskForm):
title = StringField('Title', validators=[DataRequired()])
author = StringField('Author', validators=[DataRequired()])
isbn = StringField('ISBN', validators=[DataRequired()])
submit = SubmitField('Submit')
pub/routes.py
from flask import render_template, redirect, url_for
from flask_user import login_required, current_user
from app import db
from app.models import Publication
from app.pub import bp
from app.pub.forms import EdForm
@login_required
@bp.route('/list')
def list_():
return render_template('list.html')
@bp.route('/new', methods=['POST', 'GET'])
@login_required
def new():
form = EdForm()
if form.validate_on_submit():
pub = Publication(title=form.title.data, author=form.author.data, isbn=form.isbn.data)
pub.company = current_user.current_company()
db.session.add(pub)
db.session.commit()
return redirect(url_for('pub.list_'))
return render_template('ed.html', form=form)
Testujemy dodawanie nowej publikacji przez link: http://127.0.0.1:5000/pub/new
Lista publikacji
pub/list.html
{% extends "base.html" %}
{% block head %}
{% endblock %}
{% block content %}
<h1>Publications</h1>
<a href="{{ url_for('pub.new') }}"> <span data-feather="plus-circle"></span> Add</a>
<table id="data" class="table table-striped">
<thead>
<tr>
<th>Title</th>
<th>Author</th>
<th>ISBN</th>
</tr>
</thead>
<tbody>
{% for p in user.current_company().publications %}
<tr>
<td>{{ p.title }}</td>
<td>{{ p.author }}</td>
<td>{{ p.ISBN }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
pub/routes.py
@login_required
@bp.route('/list')
def list_():
return render_template('list.html', user=current_user)
models.py
class User(db.Model, UserMixin):
# ....
def __repr__(self):
return '<User {}>'.format(self.username)
def current_company(self):
return self.companies[0]
https://datatables.net/
base.html
na końcu dopisujemy:
<script type="text/javascript" charset="utf8" src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.datatables.net/1.11.3/js/jquery.dataTables.min.js" type="text/javascript" charset="utf8" />
<script type="text/javascript" charset="utf8" src="https://cdn.datatables.net/1.11.3/js/dataTables.bootstrap5.js"></script>
<script>
feather.replace()
</script>
{% block script %}
{% endblock %}
</body>
</html>
list.html
dopisujemy na końcu:
{% block script %}
<script>
$(document).ready(function () {
$('#data').DataTable({"bPaginate": false, "bInfo": false});
});
</script>
{% endblock %}
Po odświeżeniu strony tabela powinna być rozszerzona o możliwość dynamicznego filtrowania.
https://blog.miguelgrinberg.com/post/beautiful-interactive-tables-for-your-flask-templates
Zadania dodatkowe:
Ładowanie zdjęć wykonamy w bardziej zaawansowany sposób - Ajaxem.
Do formularza publikacji dodajemy kontrolkę ładowania pliku:
class EdForm(FlaskForm):
# ...
cover_pic_file = FileField(validators=[FileAllowed(['jpg', 'png'])])
i umieszczamy ją (jako ukrytą) + wyświetlanie <img>
w ed.html
<div class="form-group">
<label for="file-input" title="cover" class="file-input-label">
<img id="uploaded_pic" src="" style="min-width: 200px; min-height: 200px; max-width: 500px; max-height: 500px;"/>
</label>
{{ form.cover_pic_file(id="file-input", class="form-control-file", style="display:none", onchange="fileSelected(this, 'uploaded_pic')", accept=".jpg,.jpeg,.png") }}
</div>
dopisujemy również kod JS odpowiedzialny za załadowanie zdjęcia na serwer bez submita:
{% block script %}
<script>
function fileSelected(fileInput, picElementId) {
let file = fileInput.files[0];
let reader = new FileReader();
reader.readAsBinaryString(file);
reader.onload = () => {
let fileUploadData = {
"action": "uploadFile",
"filename": file.name,
"content": btoa(reader.result)
};
console.log(fileUploadData);
$.ajax({
url: "/pub/upload_file",
type: "POST",
data: fileUploadData,
success: (uploadedFilePath) => {
console.log(uploadedFilePath);
$("#" + picElementId).attr("src", uploadedFilePath);
},
error: (data) => {
alert("Error");
console.log(data);
}
});
};
}
</script>
{% endblock %}
dodajemy również endpoint po stronie serwera pub/routes.py
obsługujący odbiór i zapis pliku:
@bp.route('/upload_file', methods=['POST', 'GET'])
@login_required
def upload_file():
if request.method == "POST":
action = request.form.get("action")
if action == "uploadFile":
dirpath = os.path.join(current_app.root_path, "static", "uploads")
tmpfile = NamedTemporaryFile(dir=dirpath, delete=False)
name = os.path.basename(tmpfile.name)
content = request.form.get("content")
tmpfile.write(b64decode(content))
tmpfile.close()
return os.path.join(os.sep, "static", "uploads", name)
zakładamy katalog static/uploads
Testujemy ładowanie okładki do publikacji. Po dodaniu pliku na serwerze powinien pojawić się plik o wygenerowanej nazwie w uploads/
a na stronie powinien się pojawić obrazek.
Katalog /app/static/uploads/
dodajemy do .gitingore
Zapis nazwy pliku do bazy danych
Do modelu dodajemy pole do zapisu nazwy pliku:
cover_pic = Column(String(50), index=True, unique=True)
generujemy migrację i wgrywamy do bazy danych.
Do EdForm
dodajemy kontrolkę cover_pic = HiddenField("Cover pic")
Umieszczamy ją w szablonie ed.html
: {{ form.cover_pic }}
oraz rozszerzamy kod js o zwrotny zapis nazwy pliku:
success: (uploadedFilePath) => {
console.log(uploadedFilePath);
$("#" + picElementId).attr("src", uploadedFilePath);
$('#cover_pic').val(uploadedFilePath);
},
na koniec rozszerzamy zapis obiektu Publikacji w routingu new()
pub.cover_pic=form.cover_pic.data
Edycja publikacji
Dodajemy routing routes.py
@bp.route('/ed/<int:id_>', methods=['POST', 'GET'])
@login_required
def ed(id_):
form = EdForm()
pub = Publication.query.get_or_404(id_)
form.title.data = pub.title
form.author.data = pub.author
form.isbn.data = pub.isbn
form.cover_pic.data = pub.cover_pic
return render_template('ed.html', form=form, pub=pub)
Podpinamy link edycji list.html
<td>
<a href="{{ url_for("pub.ed", id_=p.id) }}">
{{ p.title }}
</a>
</td>
Uzupełniamy ścieżkę do zdjęcia ed.html
<img alt="cover" id="uploaded_pic" src="{{ pub.cover_pic }}"
.
Po tych trzech krokach powinno nam działać wyświetlanie edycji publickacji.
W następnym kroku należy obsłużyć zapis zmian. Uzupełniamy routing
def ed(id_):
form = EdForm()
pub = Publication.query.get_or_404(id_)
if form.validate_on_submit():
pub.title = form.title.data
pub.author = form.author.data
pub.isbn = form.isbn.data
pub.cover_pic = form.cover_pic.data
db.session.add(pub)
db.session.commit()
return redirect(url_for('pub.list_'))
# ...
Po sprawdzeniu, że działa, dodajemy jeszcze sekcję usuwania starej okładki, w przypadku podmiany pliku
def ed(id_):
# ...
pub.isbn = form.isbn.data
if pub.cover_pic != form.cover_pic.data:
os.remove(os.path.join(current_app.root_path, pub.cover_pic[1:]))
pub.cover_pic = form.cover_pic.data
db.session.add(pub)
# ...
Rozszerzamy serwis o możliwość ładowania zasobów (mediów) do publikacji.
Rozszerzamy model i bazę danych:
class Publication(db.Model):
# ...
resources = relationship("Resource", backref="publication")
class Resource(db.Model):
id = Column(Integer, primary_key=True)
active = Column('is_active', Boolean(), nullable=False, server_default='1')
name = Column(String(300), nullable=False)
type = Column(String(5))
desc = Column(String(500))
file = Column(String(50))
id_publication = Column(Integer, ForeignKey('publication.id'), nullable=False)
Dodajemy routing w ramach blueprint pub
:
@login_required
@bp.route('/<int:id_pub>/res_list')
def res_list(id_pub):
pub = Publication.query.get_or_404(id_pub)
return render_template('res_list.html', user=current_user, pub=pub)
Dodajemy szablon res_list.html
:
{% extends "base.html" %}
{% block head %}
{% endblock %}
{% block content %}
<h1>{{ pub.title }}</h1>
<table id="data" class="table table-striped">
<thead>
<tr>
<th>Thumb</th>
<th>Name</th>
<th>Type</th>
</tr>
</thead>
<tbody>
{% for r in pub.resources %}
<tr>
<td>
</td>
<td>{{ r.name }}</td>
<td>{{ r.type }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
{% block script %}
<script>
$(document).ready(function () {
$('#data').DataTable({"bPaginate": false, "bInfo": false});
});
</script>
{% endblock %}
i podpinamy pod listę publikacji, jednocześnie przenosząc wejście do edycji do dedykowanego przycisku wlist.html
:
{% for p in user.current_company().publications %}
<tr>
<td>
<a href="{{ url_for("pub.res_list", id_pub=p.id) }}">
{{ p.title }}
</a>
</td>
<td>{{ p.author }}</td>
<td>{{ p.isbn }}</td>
<td><a href="{{ url_for("pub.ed", id_=p.id) }}"><span data-feather="edit-2"></span></a></td>
</tr>
{% endfor %}
Po tych zmianach powinniśmy mieć możliwość wejścia w listę zasobów podpiętych do publikacji.
Dodawanie publikacji
routes.py
@bp.route('/<int:id_pub>/res_new', methods=['POST', 'GET'])
@login_required
def res_new(id_pub):
pub = Publication.query.get_or_404(id_pub)
form = ResEdForm()
if form.validate_on_submit():
res = Resource(name=form.name.data, desc=form.desc.data)
res.file = form.file.data
res.type = form.type.data
res.publication = pub
db.session.add(res)
db.session.commit()
return redirect(url_for('pub.res_list', id_pub=pub.id))
return render_template('res_ed.html', form=form, pub=pub)
forms.py
class ResEdForm(FlaskForm):
name = StringField('Name', validators=[DataRequired()])
desc = TextAreaField('Description')
type = SelectField('Type', validators=[DataRequired()], choices=['jpg', 'png', 'epub'])
file_select = FileField(validators=[FileAllowed(['jpg', 'png'])])
file = HiddenField("File")
submit = SubmitField('Submit')
res_ed.html
{% extends "base.html" %}
{% block head %}
{% endblock %}
{% block content %}
<h1>Resource for {{ pub.title }}</h1>
<div class="container py-5">
<form action="" method="post">
<div class="row">
<div class="col">
<div class="form-group">
{{ form.hidden_tag() }}
{{ form.name.label }}
{{ form.name(class="form-control") }}
{{ form.type.label }}
{{ form.type(class='form-control') }}
{{ form.desc.label }}
{{ form.desc(class='form-control') }}
</div>
{% for field, error in form.errors.items() %}
<span style="color: red;">{{ field }} {{ error }}</span>
{% endfor %}
{{ form.submit(class='form-control btn btn-success mt-5') }}
</div>
<div class="col">
<div class="form-group">
<label for="file-input" title="file" class="file-input-label">
<img alt="Click here to upload a file..." id="uploaded_pic" src=""
style="min-width: 200px; min-height: 200px; max-width: 500px; max-height: 500px;"/>
</label>
{{ form.file_select(id="file-input", class="form-control-file", style="display:none", onchange="fileSelected(this, 'uploaded_pic')", accept=".jpg,.jpeg,.png") }}
{{ form.file }}
</div>
</div>
</div>
</form>
</div>
{% endblock %}
{% block script %}
<script>
function fileSelected(fileInput, picElementId) {
let file = fileInput.files[0];
let reader = new FileReader();
reader.readAsBinaryString(file);
reader.onload = () => {
let fileUploadData = {
"action": "uploadFile",
"filename": file.name,
"content": btoa(reader.result)
};
console.log(fileUploadData);
$.ajax({
url: "/pub/upload_file",
type: "POST",
data: fileUploadData,
success: (uploadedFilePath) => {
console.log(uploadedFilePath);
$("#" + picElementId).attr("src", uploadedFilePath);
$('#cover_pic').val(uploadedFilePath);
},
error: (data) => {
alert("Error");
console.log(data);
}
});
};
}
</script>
{% endblock %}
i podpinanym przycisk dodawania w res_list.html
<a href="{{ url_for('pub.res_new', id_pub=pub.id) }}"> <span data-feather="plus-circle"></span> Add</a>