Warsztaty "Projekt informatyczny z wykorzystaniem narzędzi: GitLab, Git, Flask Python"

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.

diagram

Zarządzanie projektem - GitLab

Utworzenie konta użytkownika na GitLab https://gitlab.com/.

  1. Utworzenie testowego projektu z szablonu: Sample GitLab Project i zapoznanie się z modułami Project information, Repository, Issues, Wiki https://docs.gitlab.com/
  2. Zaproszenie uczestników szkolenia do swojego projektu
  3. Edycja pliku REAMDE.md (automatyczne aktualizowanie w repozytorium) w swoim projekcie i projektach innych uczestników
  4. Zapoznanie się ze składaniom markdown i edycja pliku README zgodnie z tymi zasadami https://docs.gitlab.com/ee/user/markdown.html, https://daringfireball.net/projects/markdown/syntax.
  5. (dodatkowe) Wygenerowanie kluczy ssh i załadownie do swojego projektu
  6. Założenie wspólnego projektu w ramach którego jako zespół prowadzone będą dalsze prace.

Środowisko programistyczne - PyCharm

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,

clone

i podać go w Pycharm

checkout

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

Interpreter

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

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

.gitignore

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.

Hello world

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

Init project

Stworzenie gałęzi devel

GitFlow

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.

  1. Założenie gałęzi devel z poziomu GitLab.
  2. Pull w Pycharm
  3. Checkout devel

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

Config

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.

create_app

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.

Baza danych

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

Flask-User

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'

Bootstrap

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:

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:

Company - Sygnały, Relacje N:N

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

Publikacje - Blueprints

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]

Dynamiczne filtrowanie tabeli

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:

Okładki publikacji - ładowanie plików Ajax

Ł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)
    # ...        

Zasoby - CRUD

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>