Matplotlib – osadzenie wykresu w aplikacji

Kod programu można pobrać tutaj.

Okno programu zostało utworzone w bibliotece tkinter. Na wykres została przewidziana osobna ramka tkinter.Frame(). W ramce tej został umieszczony wykres dzięki zastosowaniu obiektu FigureCanvasTkAgg. Obiekt ten jako parametry przyjmuje obiekt Figure oraz kontener – w tym przypadku obiekt Frame.

W oknie programu umieściłem podstawowe widżety tzn. menu programu, pole wprowadzania i przycisk tkinter.Button() umożliwiające zmianę roku, dla którego generowany jest wykres oraz pole statusu, w którym są wyświetlane komunikaty o poprawnym wczytaniu danych lub o braku danych do wygenerowania wykresu dla danego roku.

Domyślnie po uruchomieniu programu wyświetlany jest wykres cen hurtowych dla bieżącego roku.

Okno programu generującego wykres cen hurtowych paliw na podstawie podanego roku.

Matplotlib i parsowanie HTML

W dzisiejszym wpisie przedstawię parsowanie strony Orlenu w celu pobrania danych o zmianach cen hurtowych oleju napędowego i przedstawienia tych zmian na wykresie.

Na wstępie niezbędne jest zaimportowanie bibliotek: requests – w celu obsługi zapytań HTLM, lxml .html do parsowania HTML, matplotlib.pyplot – w celu wyświetlenia wykresu na podstawie sparsowanych danych oraz unicodedata – w celu obsługi unicode.

import requests
from lxml import html
import matplotlib.pyplot as plt
import unicodedata

if __name__ == "__main__":
    main()

def main():
   print_chart(my_parser(2019))    #wyświetla wykres dla 2019 roku

def print_chart(content):
    year = content.get('year')  
    fuel = content.get('fuel')
    dates = content.get('dates') 
    prices = content.get('prices')

    plt.style.use('bmh')

    fig, ax = plt.subplots()

    ax.set_title(f'Cena dla paliwa {fuel} - {year} rok')
    ax.set_xlabel('Data')
    ax.set_ylabel('Cena')
    fig.autofmt_xdate()
    ax.grid(True)
    ax.xaxis.set_major_locator(plt.MaxNLocator(10))
    ax.plot(dates, prices, c='#CA3F62')
    plt.show()

Powyższa funkcja rysuje wykres liniowy zmian cen hurtowych oleju napędowego w danych roku. Argument content przekazywany do funkcji jest słownikiem. Wartości dla kolejnych kluczy słownika są przekazywane do zmiennych year, fuel, dates, i prices. Wartości dates i prices są listami przechowującymi odpowiednio daty i ceny hurtowe Orlenu.

Następnie w wierszu plt.style use(’bmh’) zostaje użyty styl graficzny dla wykresu w celu poprawy wyglądu wykresu. Listę dostępnych stylów można wyświetlić za pomocą: print(plt.style.available).

Następnie za pomocą jako wynik funkcji plt.subplots() zostaje zwrócona krotka, której wartości są przypisane do zmiennych – odpowiednio fig oraz ax. Zmienna fig przechowuje obiekt Figure, który można utożsamiać z oknem wszystkich wykresów, natomiast ax przechowuje obiekt AxesSubplot, który możemy utożsamiać z poszczególnym wykresem, jednakże na takim obiekcie może być rysowanych kilka wykresów dla różnych danych np. obiekt taki może przechowywać wykres zmian cen oleju napędowego oraz drugi wykres, zawierający cenę etyliny w tym okresie. Jeżeli chcielibyśmy przedstawić oba wykresy w tym samym oknie, w tym samym obiekcie Figure, ale np. w dwóch kolumnach, to należałoby użyć dwóch obiektów ax np. ax1 i ax2 tzn.

fig, (ax1, ax2) = plt.subplots(rows=1, cols=2)

W kolejnych wierszach ustawiany jest tytuł dla wykresu, etykieta dla osi X i etykieta dla osi Y. Następnie wywołuję funkcję autofmt_xdate(), aby poprawić widoczność etykiet dla osi X – etykiety wyświetlane są pod kątem i nie przesłaniają się nawzajem.

Kolejnym krokiem jest wyświetlenie siatki wykresu za pomocą funkcji grid() przekazując parametr True – w celu poprawy wyglądu wykresu.

Można zauważyć, że dane osi X przesłaniają się mimo wszystko. Aby poprawić widoczność wartości na osi x ograniczyłem ilość wyświetlanych wartości na osi X do 10 za pomocą funkcji: ax.xaxis.set_major_locator(plt.MaxNLocator(10))

W kolejnym wierszu: ax.plot(dates, prices, c=’#CA3F62′) tworzony jest wykres liniowy na podstawie danych dla osi X – zawartych w liście dates oraz dla danych dla osi Y zawartych w liście prices. Parametr c określa kolor linii wykresu.

Funkcja show() w kolejnej linii wyświetla tak przygotowany wykres.

Słownik content przekazany jako argument do funkcji print_chart() jest zwracany jako wynik funkcji my_parser(), która jako parametr przyjmuje argument year – rok, dla którego chcemy pobrać dane. Kod mojej funkcji my_parser() wygląda następująco:

def my_parser(year):
    page = requests.get(f'https://www.orlen.pl/PL/DlaBiznesu/HurtoweCenyPaliw/Strony/archiwum-cen.aspx?Fuel=ONEkodiesel&Year={year}')
    text = unicodedata.normalize("NFKC", page.text)
    tree = html.fromstring(text)
    table_content = tree.xpath('//tr/td/span/text()')
    table_content = table_content[::-1]
    content = {}
    content['year'] = year
    content['fuel'] = table_content.pop()
    content['dates'] = table_content[1::2]
    prices = table_content[::2]
    prices = [price.replace(' ', '') for price in prices]
    prices = list(map(int, prices))
    content['prices'] = prices
    return content

Najpierw za pomocą funkcji get() z biblioteki requests jest tworzone zapytanie strony Orlenu dla danego roku, podanego jako parametr funkcji my_parser(). W efekcie zmienna page przechowuje zwrócony obiekt Response.

W kolejnym etapie zawartość strony jest normalizowana, aby pozbyć się z tekstu kodów unicode.

W linii tree = html.fromstring(text) tworzona jest zmienna tree, za pomocą której następnie będzie wyszukiwana zawartość tabeli, przechowującej daty i ceny paliwa tzn.:

table_content = tree.xpath(’//tr/td/span/text()’) # pobranie wartości z tabeli

Pobrana zawartość z tabeli jest listą zawierającą kolejno: nazwę paliwa, a następnie na przemian daty i ceny paliwa. Dane są posortowane od końca roku do początku i aby uzyskać odwrotne wartości należy wpisać:

table_content = table_content[::-1]

Dzięki temu dane z początku roku będą na początku wykresu.

Kolejnym krokiem jest utworzenie słownika content, którego kluczami będą: year, fuel, dates i prices, przechowującymi odpowiednio rok, dla którego chcemy wyświetlić dane; nazwę paliwa; zawierający listę dat i listę cen hurtowych. Wartość year jest przekazywana na podstawie parametru funkcji my_parser(). Wartość fuel jest pobrana za pomocą funkcji pop() z listy table_content – dzięki czemu docelowo lista ma tylko dane zawierające daty i ceny. W liście prices jest są dokonywane czynności porządkowe tzn. usunięcie spacji oddzielających tysiące oraz zrzutowanie tak zmodyfikowanych łańcuchów na typ int.

Tak utworzony słownik jest następnie zwracany jako argument do funkcji print_chart()

W efekcie wykres cen hurtowych dla oleju napędowego dla przykładowego roku 2019 wygląda następująco:

Wykres hurtowych cen oleju napędowego – PKN Orlen w 2019 roku.

W kolejnym wpisie opiszę jak osadzić wykres utworzony z danych pobranych ze strony PKN Orlen we własnej aplikacji na podstawie opisanej już wcześniej biblioteki tkinter.

— c. d. n. —

Mapy kolorów w matplotlib

Aby utworzyć wykres punktowy, korzystający z wybranej palety kolorów należy jako argument cmap podać nazwę wybranego schematu kolorów. Kolory pogrupowane są w grupy >>dokumentacja<< – np. Sequential – różne natężenie jednego koloru, Diverging – różne natężenia dwóch kontrastowych kolorów, Qualitative – kontrastowe różne kolory, ale pasujące do palety np. Pastel1 – zawierający różne kolory pastelowe.

import matplotlib.pyplot as plt

fig, ax = plt.subplots()

ax.set_title('Tytuł wykresu')
ax.set_xlabel('Etykieta osi X')
ax.set_ylabel('Etykieta osi Y')

# x - lista z wartościami osi X np. nazwa produktu
# y - lista z wartościami osi Y np. cena produktu
# intensivity - lista z liczbami odpowiadającymi instensywności danej cechy
# cm - nazwa wybranej palety kolorów

ax.scatter(x, y, c=intensivity,  cmap=cm)  
  # Przykładowo: plt.scatter(x, y, c=intensivity, s=50,  cmap='plasma')

mappable = ax.collections[0]
cbar = fig.colorbar(mappable=mappable)
cbar.set_label('intensivity')

plt.show()

Parametr s odpowiada za wielkość rysowanych znaków.

Aby wyświetlić pasek colorbar, który obrazuje intensywność danej cechy dla wyświetlonego punktu należy jako parametr podać obiekt mappable, który dla wykresu punktowego jest przechowywany jako element listy collections obiektu AxesSubplot.

W efekcie na wykresie, który może przedstawiać np. nazwę produktu i cenę można umieścić dodatkową informację za pomocą koloru np. popularność wśród kupujących lub ilość towaru w magazynie (np. kolor zielony – towar dostępny bez problemu aż po kolor czerwony – brak towaru w magazynie)

Django #3

We wcześniejszych wpisach opisałem zarządzanie użytkownikami w aplikacji tzn. nowy użytkownik może się zarejestrować – utworzyć konto w witrynie, użytkownik zarejestrowany może się zalogować, wylogować lub zmienić swoje hasło.

Następnie opiszę główną funkcjonalność witryny tzn. ewidencję aut i napraw. W tym celu w pliku urls.py projektu załączę mapowanie adresów związanych z aplikacją cars. Po zmianach plik urls.py projektu będzie wyglądał następująco:

from django.contrib import admin
from django.urls import path, include
from users import views as users_views
from django.contrib.auth import views as auth_views

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('cars.urls')),
    path('register-user/', users_views.RegisterUser.as_view(
        template_name='users/register-user.html'), name='register_user'),
    path('login/', auth_views.LoginView.as_view(template_name='users/login.html'), name='login'),
    path('logout/', auth_views.LogoutView.as_view(template_name='users/logout.html'), name='logout'),
    path('change-password/', users_views.ChangePassword.as_view(
        template_name='users/change-password.html'), name='change_password'),
]

Można zauważyć, że mapowanie adresów na odpowiednie widoki uwzględnia też plik urls.py utworzony w aplikacji cars. Plik urls.py z aplikacji cars wyglada następująco:

from django.urls import path
from .views import (
    CarsListView,
    AddCarView,
    RepairsListView,
    UpdateCarView,
    DeleteCarView,
    AddRepairView
)
urlpatterns = [
    path('', CarsListView.as_view(), name='cars'),
    path('add-car/', AddCarView.as_view(), name='add_car'),
    path('car/', RepairsListView.as_view(), name='car_detail'),
    path('car/<int:pk>/update/', UpdateCarView.as_view(), name='update_car'),
    path('car/<int:pk>/delete/', DeleteCarView.as_view(), name='delete_car'),
    path('car/<int:pk>/new-repair/', AddRepairView.as_view(), name='add_repair'),
]

Główny widok definiowany w klasie CarListView wyświetla wszystkie auta użytkownika.

Należy pamiętać o dopisaniu aplikacji w pliku settings.py projektu w liście INSTALLED_APPS np. dla aplikacji cars będzie to element: 'cars.apps.CarsConfig’.

W aplikacji users korzystałem z preinstalowanego modelu użytkownika. W aplikacji cars należy stworzyć modele – obiekty mapujące tabele bazy danych. Modele te będą definiować: pojazd – model Car i naprawę – model Repair. Kod pliku models.py aplikacji cars wygląda następująco:

from django.db import models
from django.contrib.auth.models import User
from django.utils import timezone
from django.core.validators import MaxValueValidator, MinValueValidator


class Car(models.Model):
    make = models.CharField(max_length=10)
    model = models.CharField(max_length=10)
    vrn = models.CharField(max_length=10)
    year = models.IntegerField(default=timezone.now().year,
                               validators=[MinValueValidator(timezone.now().year - 100),
                                           MaxValueValidator(timezone.now().year)])
    user = models.ForeignKey(User, on_delete=models.CASCADE)

    def __str__(self):
        return f'{self.make} {self.model}'


class Repair(models.Model):
    date = models.DateField(default=timezone.now)
    description = models.TextField()
    car = models.ForeignKey(Car, on_delete=models.CASCADE)

    def __str__(self):
        lead = self.description[:10] + '...'
        return lead

Model Car zawiera atrybuty make, model, vrn, year i user opisujące markę pojazdu, model, nr rejestracyjny pojazdu i rok produkcji, a także klucz obcy, zawierający obiekt użytkownika, który utworzył wpis o pojeździe. Pole year posiada walidatory, uniemożliwiające wpisanie auta o roku produkcji o 100 lat starszym od bieżącej daty i nowszym niż bieżący rok. Opcja on_delete atrybutu user ma wartość domyślną CASCADE tzn. usunięcie użytkownika, który wprowadzić powoduje także usunięcie wszystkich wprowadzonych przez niego aut.

Model Repair zawiera atrybuty date – data naprawy, description – szczegóły naprawy oraz car, będący kluczem obcym – jakiego auta dotyczy dana naprawa. Metoda __str__() wyświetla nagłówek będący początkiem opisu naprawy (10 początkowych znaków).

Aby móc dokonywać edycji wpisów obu modeli z poziomu panelu administracyjnego trzeba dopisać oba modele do pliku admin.py aplikacji cars tzn.

from django.contrib import admin
from .models import Car, Repair

admin.site.register(Car)
admin.site.register(Repair)

W następnej części opiszę widoki definiujące poszczególne funkcjonalności tzn. wyświetlanie aut należących do użytkownika, dodawanie napraw, a także wyszukiwanie aut za pomocą pola wyszukiwania na stronie.

>>część czwarta<<

car search
car search

Django #2

Widoki logowania i wylogowania są predefiniowane jako klasy LoginView i LogoutView więc wystarczy tylko umieścić odpowiednie wpisy w pliku urls.py (jak opisałem w części 1) i utworzyć pliki szablonów tzn.

Listing szablonu login.html:

{% extends 'cars/base.html' %}
{% load crispy_forms_tags %}


{% block content %}
  <div class="row justify-content-center">
    <div class="col-4">
      <form method="POST" class="form-control-sm">
       {% csrf_token %}
       <fieldset class="form-group">
         <legend class="border-bottom text-primary">
            Log In
          </legend>
          {{ form|crispy }}
        </fieldset>
        <button class="btn btn-secondary" type="submit">Login</button>
        <p class="text-muted text-small mt-5 ml-2">Need an Account? <a href="{% url 'register_user' %}">Sign up</a></p>
        </form>
      </div>
    </div>
{% endblock %}
Szablon login.html

Listing szablonu logout.html:

{% extends 'cars/base.html' %}

{% block content %}
 <div class="row justify-content-center mb-5">
    <div class="col-4">
	   <h4 class="border-bottom text-primary mt-2">You're logged out now</h4>	
  <div class="text-muted text-small ml-2 mt-5">Back to: <a href="{% url 'login' %}">login page</a></div>
  </div>
 </div>
{% endblock %}
Szablon logout.html

Pozostało jeszcze utworzenie widoku odpowiedzialnego za zmianę hasła. Klasa widoku będzie dziedziczyć po klasie CreateView jak również po klasie LoginRequiredMixin. Klasa LoginRequiredMixin dodaje funkcjonalność polegającą na tym, że zmiany hasła może dokonać tylko zalogowany użytkownik.

Kod klasy widoku ChangePassword():

class ChangePassword(LoginRequiredMixin, CreateView):
    form_class = PasswordChangeForm

    def get(self, request, *args, **kwargs):
        form = PasswordChangeForm(request.user)
        return render(request, 'users/change-password.html', {'form': form})

    def post(self, request, *args, **kwargs):
        form = PasswordChangeForm(request.user, request.POST)
        if form.is_valid():
            user = form.save()
            update_session_auth_hash(request, user)
            messages.success(request, 'Password changed!')
            return redirect('cars')
        return render(request, 'users/change-password.html', {'form': form})

W powyższej klasie przesłaniam metody get() i post(). Gdy strona jest wyświetlana za pomocą metody get() to wyświetlany jest pusty formularz zmiany hasła dla zalogowanego użytkownika. Gdy strona jest wyświetlana za pomocą metody post(), to dokonywana jest walidacja przesłanych danych i jeśli są one poprawne następuje zapisanie zmienionego hasła, wyświetlenie komunikatu o zmianie hasła i przekierowanie na stronę o nazwie cars.

Pozostaje dodać plik szablonu tzn. change-password.html:

{% extends 'cars/base.html' %}
{% load crispy_forms_tags %}


{% block content %}
  <div class="row justify-content-center">
   <div class="col-4">
	  <form method="POST" class="form-control-sm">
      {% csrf_token %}
      <fieldset class="form-group">
        <legend class="border-bottom text-primary">
            New Password Settings
        </legend>
        {{ form|crispy }}
      </fieldset>
      <button class="btn btn-secondary" type="submit">Save</button>
    </form>
   </div>
  </div>
{% endblock %}
Szablon change-password.html

>>część 3<<

Django #1

We >>wstępie<< opisałem główne założenia projektu.

Poniżej zamieszczam listing wymaganych pakietów do projektu (wygenerowany przez pip freeze):

asgiref==3.2.5
Django==3.0.4
django-crispy-forms==1.9.0
pytz==2019.3
sqlparse==0.3.1

Polecam utworzyć osobne środowisko wirtualne dla projektu (jak stworzyć wirtualne środowisko opisałem >>tutaj<<).

Wszystkie pakiety oprócz django-crispy-forms instalowane są podczas instalacji pakietu django.

W katalogu zawierającym również podkatalog venv tworzymy projekt za pomocą komendy: django-admin startproject nazwa_projektu

Startproject tworzy główny katalog projektu oraz plik manage.py, dzięki któremu można będzie zarządzać projektem.

Następnie można utworzyć administratora projektu, czyli użytkownika o maksymalnych uprawnieniach administracyjnych, dzięki czemu będzie można używać webowego panelu administracyjnego. Jednak najpierw należy przeprowadzić migracje: python manage.py migrate dzięki czemu zostają utworzone odpowiednie tabele w bazie danych np. admin, auth itd. Po przeprowadzonej migracji można utworzyć konto administracyjne za pomocą komendy: python manage.py createsuperuser

Katalog projektu zawiera następujące pliki i katalogi:
├── nazwa_projektu
│   ├── asgi.py
│   ├── init.py
│   ├── pycache
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
├── db.sqlite3
└── manage.py

Domyślnie dane umieszczane zostają w bazie sqlite3 (standardowo plik db.sqlite3), co jest dobrym rozwiązaniem podczas tworzenia projektu, a finalnie można użyć innej bazy (Django zawiera wsparcie dla PostgreSQL, MySQL i wielu innych). Zmiana domyślnej bazy wymaga tylko modyfikacji danych z sekcji DATABASES pliku settings.py projektu.

Aby odseparować poszczególne elementy składowe projektu wydzieliłem dwie części składowe tzn. odpowiedzialną za zarządzanie użytkownikami – users oraz za zarządzanie pojazdami – cars. W Django są to tzw. aplikacje. Aby je utworzyć należy wpisać: python manage.py startapp users oraz python manage.py startapp cars

Zacznę od aplikacji users, w której nowi użytkownicy będą mogli tworzyć konta, oraz logować się, wylogowywać, a także zmieniać swoje hasło dostępowe.

Aby określić mapowanie adresów URL na konkretne klasy widoków modyfikuję plik urls.py projektu tzn.

from django.contrib import admin
from django.urls import path
from users import views as users_views
from django.contrib.auth import views as auth_views

urlpatterns = [
    path('admin/', admin.site.urls),
    path('register-user/', users_views.RegisterUser.as_view(
        template_name='users/register-user.html'), name='register_user'),
    path('login/', auth_views.LoginView.as_view(template_name='users/login.html'), name='login'),
    path('logout/', auth_views.LogoutView.as_view(template_name='users/logout.html'), name='logout'),
    path('change-password/', users_views.ChangePassword.as_view(
        template_name='users/change-password.html'), name='change_password'),
]

Przykładowo po wpisaniu adresu kończącego się na: register-user/ zostaje wywołana klasa RegisterUser() z pliku views.py oraz do tego przypisania zostaje nadana nazwa, której będziemy mogli używać w szablonach. Aby klasa mogła być użyta jako widok, należy wywołać jej metodę as_view(). Jako argument podałem argument template_name, w którym podaję nazwę szablonu, który wyświetla podaną stronę. Równie dobrze można by podać ten argument jako atrybut klasy w widoku, ale aby zachować spójność z pozostałymi widokami umieściłem go w tym miejscu.

kod klasy RegisterUser() z pliku views.py aplikacji users wygląda następująco (na początku umieściłem wymagane importy)

from django.shortcuts import render, redirect
from django.contrib.auth.forms import UserCreationForm, PasswordChangeForm
from django.contrib.auth import login, authenticate
from django.views.generic import CreateView
from django.contrib import messages

class RegisterUser(CreateView):
    form_class = UserCreationForm

    def get(self, request, *args, **kwargs):
        return render(request, 'users/register-user.html', {'form': UserCreationForm()})

    def post(self, request, *args, **kwargs):
        form = UserCreationForm(request.POST)
        if form.is_valid():
            form.save()
            username = form.cleaned_data.get('username')
            password = form.cleaned_data.get('password1')
            user = authenticate(username=username, password=password)
            login(request, user)
            messages.success(request, f'Welcome, {username}!')
            return redirect('cars')
        return render(request, 'users/register-user.html', {'form': form})

Klasa RegisterUser() dziedziczy po klasie CreateView, a formularz jest definiowany przez klasę UserCreationForm. Gdy strona zostaje wyświetlona za pomocą metody GET to pojawia się pusty formularz, gdy natomiast podane są już jakieś dane w formularzu, to są one przekazane za pomocą metody POST. Aby obsłużyć GET i POST przesłaniam odpowiednio metody get() i post() klasy RegisterUser(). W metodzie post() sprawdzana jest poprawność formularza i jeśli formularz jest poprawny to następuje zapisanie danych z formularza tzn. utworzenie użytkownika za pomocą metody save(). Domyślnie jest to użytkownik pozbawiony przywilejów administracyjnych. Zostaje on zalogowany przy użyciu podanych przy rejestracji danych. Nowa wiadomość z tagiem Success zostanie wyświetlona po przekierowaniu na adres o nazwie cars. Wiadomość wyświetla się tylko jednokrotnie na stronie.

Pozostaje jeszcze utworzyć szablon register-user.html dla RegisterUser(). W tym celu tworzę katalog szablonów w aplikacji users zgodnie z konwencją – w katalogu templates należy utworzyć kolejny katalog z nazwą zgodną z nazwą aplikacji tzn.

nazwa_projektu/users/templates/users/register-user.html

Kod pliku register-user.html jest częścią większej całości, a ściślej nie utworzonego na razie pliku base.html z aplikacji cars. Aplikacje mogą korzystać ze swoich szablonów, a dzięki temu wygląd zdefiniowany w aplikacji cars w szablonie base.html jest zachowany w szablonie register-user. W szablonie jest też włączone korzystanie z tagów crispy_forms, dzięki którym formularz wygląda lepiej. Każdy formularz musi zawierać csrf_token, dzięki czemu strona jest automatycznie odporna na pewne formy ataków (szczegóły >>tutaj<<).

Listing pliku register-user.html wygląda następująco:

{% extends 'cars/base.html' %}
{% load crispy_forms_tags %}

{% block content %}
  <div class="row justify-content-center">
    <div class="col-4">
      <form method="POST" class="form-control-sm">
        {% csrf_token %}
        <fieldset class="form-group">
          <legend class="border-bottom text-primary">
            Registration
          </legend>
            {{ form|crispy }}
        </fieldset>
          <button class="btn btn-secondary" type="submit">Register</button>
      </form>
    </div>
  </div>
{% endblock %}
Szablon register-user.html

>>część 2<<

Django – framework webowy

W tej serii opiszę podstawy frameworka Django na przykładzie aplikacji do notowania napraw związanych z posiadanymi autami.

Kod projektu jest do pobrania tutaj.

Aplikacja jest podobna do tej opisanej we  >>wpisie<<, ale zastosowanie frameworku webowego wymusiło dodatkowe funkcjonalności, np. obsługę kont użytkowników.

>>część 1<<

DatePicker w bibliotece tkinter

Pliki projektu można pobrać tutaj.

Kod mojej klasy DatePicker wygląda następująco:

import tkinter as tk
import calendar
from datetime import datetime
from functools import partial


class DatePicker():
    def __init__(self, root, date_entry, date_strf):
        self.root = root
        self.date_entry = date_entry
        self.date_strf = date_strf
        self.top_level = tk.Toplevel(self.root)
        self.top_level.grab_set()
        self.top_level.title('Date picker')
        x = self.root.winfo_rootx()
        y = self.root.winfo_rooty()
        width = self.root.winfo_width()
        height = self.root.winfo_height()
        self.top_level.geometry(
            '+{}+{}'.format(x + int(width / 2), y + int(height / 2)))

W metodzie __init__() tworzone jest okno potomne jako instancja klasy tk.Toplevel. Nastepnie blokowana jest możliwość aktywowania elementów interfejsu okna rodzica do momentu zamknięcia okna date picker. W kolejnych wierszach definiuję ustawienie okna date picker względem okna rodzica.

        self.c = calendar
        self.cal = self.c.Calendar(self.c.firstweekday())
        self.dp_frame = None
        self.create_cal_ui()

Następnie tworzę egzemplarz klasy Calendar, deklaruję zmienną dp_frame, która będzie przechowywać instancję klasy tk.Frame, a następnie aktywuję metodę create_cal_ui(), która rozmieszcza widżety w utworzonym oknie.

Kod metody create_cal_ui() wygląda następująco:

    def create_cal_ui(self, year=datetime.today().year, month=datetime.today().month):

        mc = self.cal.monthdayscalendar(year, month)
        self.month = month
        self.year = year
        month_txt = self.c.month_name[month]
        self.date_str = f'{month_txt} {year}'

        if self.dp_frame is not None:
            self.dp_frame.destroy()

        self.dp_frame = tk. Frame(self.top_level)
        self.dp_frame.grid(column=0, row=0)

        self.prev_img = tk.PhotoImage(file='Resources/prev.gif')
        self.next_img = tk.PhotoImage(file='Resources/next.gif')
        prev_btn = tk.Button(self.dp_frame, image=self.prev_img, relief='flat')
        prev_btn.bind(
            '<Button-1>', lambda event: self.set_date(event, 'prev_btn'))
        prev_btn.grid(row=0, column=0)
        next_btn = tk.Button(self.dp_frame, image=self.next_img, relief='flat')
        next_btn.bind(
            '<Button-1>', lambda event: self.set_date(event, 'next_btn'))
        next_btn.grid(row=0, column=6)
        self.date_lbl = tk.Label(self.dp_frame, text=self.date_str,
                                 font=12)
        self.date_lbl.grid(row=0, column=1, columnspan=5, sticky='WE')

        week_names = self.c.day_abbr
        for i, name in enumerate(week_names):
            label = tk.Label(self.dp_frame, text=name).grid(column=i, row=1)

        col = 0
        row = 2
        for week in mc:
            for day in week:
                state = 'normal'
                if day == 0:
                    state = 'disabled'
                    day = ''
                day = str(day)
                button = tk.Button(self.dp_frame, text=day,
                                   relief='flat', state=state, command=partial(self.get_date, day))
                button.grid(column=col, row=row)
                col += 1
            row += 1
            col = 0

Metoda create_cal_ui() pobiera jako argumenty rok oraz miesiąc, dla którego ma być wyświetlony kalendarz. Domyślnie wyświetlany jest kalendarz dla bieżącego miesiąca i bieżącego roku. Na wstępie za pomocą metody monthdayscalendar() klasy Calendar pobierana jest lista dni w miesiącu – każdy tydzień jest opisywany jako osobna lista tzn. metoda zwraca listę list. Dni nie należące do wybranego miesiąca są oznaczane jako 0. W zmiennej month_txt przechowywana jest tekstowa nazwa miesiąca, którego wartość liczbową przechowuje zmienna self.month.

 if self.dp_frame is not None:
            self.dp_frame.destroy()

Powyższa instrukcja warunkowa sprawdza, czy nie dokonano wyboru kolejnego miesiąca. W takim przypadku bieżąca ramka przechowująca elementy dla wcześniej utworzonego miesiąca zostaje usunięta.

Następnie w ramce dp_frame tworzone są przyciski do zmiany miesiąca prev_btn, next_btn, etykieta wyświetlająca aktualnie wybrany miesiąc i rok date_lbl oraz przyciski reprezentujące poszczególne dni w miesiącu.

Zmiana miesiąca na poprzedni lub kolejny dokonywana jest dokonywana za pomocą dowiązania do przycisku – metoda bind. Zdarzenie (event) przesyłane do metody po aktywowaniu przycisku w tkinterze nie zawiera danych np. o nazwie wciśniętego przycisku. Aby zidentyfikować, jaki przycisk aktywował daną metodę musiałem utworzyć anonimową funkcję, która przesyła dodatkowy argument do metody obsługującej aktywowanie przycisku tzn.

prev_btn.bind(
            '<Button-1>', lambda event: self.set_date(event, 'prev_btn'))
next_btn.bind(
            '<Button-1>', lambda event: self.set_date(event, 'next_btn'))

Powyższy kod umożliwia uruchomienie metody set_date() po kliknięciu na odpowiedni przycisk, przy czym do metody set_date() jest przesyłany również argument typu String, identyfikujący który przycisk wywołał metodę.

Poniższy kod wyświetla nagłówek dni tygodnia w wyświetlanym kalendarzu. Nazwy dni wyświetlane są w formie skróconej:

    week_names = self.c.day_abbr
    for i, name in enumerate(week_names):
        label = tk.Label(self.dp_frame, text=name).grid(column=i, row=1)

col = 0
        row = 2
        for week in mc:
            for day in week:
                state = 'normal'
                if day == 0:
                    state = 'disabled'
                    day = ''
                day = str(day)
                button = tk.Button(self.dp_frame, text=day,
                                   relief='flat', state=state, command=partial(self.get_date, day))
                button.grid(column=col, row=row)
                col += 1
            row += 1
            col = 0

Powyższy kod wyświetla przyciski dni dla wybranego miesiąca. Jeśli tydzień zawiera dni z poprzedniego lub następnego miesiąca, to przyciski dla tych dni są dezaktywowane. Po naciśnięciu na wybrany przycisk uruchamiana jest metoda get_date() za pomocą funkcji partial modułu functools. Lambda w tym przypadku nie zadziała tak, jak można by oczekiwać z powodu tzn. late binding.

Kod metody set_date() uruchamiany po naciśnięciu przycisków: next_btn oraz prev_btn:

    def set_date(self, event, sender):
        if sender == 'prev_btn':
            self.month -= 1
            if self.month < 1:
                self.month = 12
                self.year -= 1
        if sender == 'next_btn':
            self.month += 1
            if self.month > 12:
                self.month = 1
                self.year += 1
        self.create_cal_ui(self.year, self.month)

Powyższa metoda jako argument sender pobiera nazwę przycisku, który ją aktywował. Niestety argument event nie udostępnia takiej informacji. Po kliknięciu następuje uaktualnienie widżetów poprzez wywołanie metody create_cal_ui().

Kod metody get_date() uruchamianej po wybraniu konkretnej daty z date pickera wygląda następująco:

def get_date(self, day):
    day = int(day)
    self.date_entry.delete(0, tk.END)
    d = datetime(self.year, self.month, day)
    self.date_entry.insert(0, d.strftime(self.date_strf))
    self.top_level.destroy()

W powyższej metodzie wybrany dzień – argument day jest rzutowany na obiekt int, dzięki czemu można utworzyć egzemplarz klasy datetime na podstawie wybranego dnia, miesiąca i roku. Następnie kasowane jest pole date_entry w oknie rodzica i wypełniane tekstem reprezentującym wybraną datę na podstawie wybranego formatowania. Po aktualizacji widżetu z okna rodzica, okno date pickera zostaje usunięte.

GUI do programu CRUD cz.3

Poniżej opisałem kod klasy NewCarWindow opisującej nowe okno potomne, tworzone gdy kliknięto przycisk add_car_button:

import tkinter as tk
from sql_helper import Helper


class NewCarWindow():
    def __init__(self, root, parent):
        self.top_level = tk.Toplevel(root)
        self.helper = Helper()
        self.parent = parent
        x = self.top_level.winfo_screenwidth()
        y = self.top_level.winfo_screenheight()
        geometry = '+{}+{}'.format(int((x / 2) - 100),
                                   int((y / 2) - 100))
        size = self.top_level.geometry(geometry)

Najpierw importuję wymagane klasy, następnie w metodzie __init__() ustalane jest położenie okna potomnego względem okna głównego.

        self.top_level.title('Add car')
        self.top_level.grab_set()
        label_make = tk.Label(self.top_level, text='Make: ',
                              font=12, padx=20, pady=10).grid(row=0, column=0)
        label_model = tk.Label(self.top_level, text='Model: ',
                               font=12, padx=20, pady=10).grid(row=1, column=0)
        label_year = tk.Label(self.top_level, text='Year: ',
                              font=12, padx=20, pady=10).grid(row=2, column=0)
        label_vrn = tk.Label(self.top_level, text='VRN: ',
                             font=12, padx=20, pady=10).grid(row=3, column=0)
        label_vin = tk.Label(self.top_level, text='VIN: ',
                             font=12, padx=20, pady=10).grid(row=4, column=0)

Następnie ustawiany jest tytuł okna potomnego. Metoda grab_set() powoduje, że okno główne jest nieaktywne – nie można np. wcisnąć żadnego przycisku itp. Kolejnym krokiem jest ustawienie etykiet tekstowych opisujących pola wprowadzania, które definiuję poniżej, wraz z ze zmiennymi typu StringVar, przechowującymi wartości wprowadzane do tych pól:

    self.make_sv = tk.StringVar()
    self.model_sv = tk.StringVar()
    self.year_sv = tk.StringVar()
    self.vrn_sv = tk.StringVar()
    self.vin_sv = tk.StringVar()
    self.info_sv = tk.StringVar()

    entry_make = tk.Entry(
            self.top_level, text=self.make_sv)
    entry_make.focus_set()
    entry_make.grid(row=0, column=1)
    entry_model = tk.Entry(
            self.top_level, text=self.model_sv).grid(row=1, column=1)
    entry_year = tk.Entry(
            self.top_level, text=self.year_sv).grid(row=2, column=1)
    entry_vrn = tk.Entry(
            self.top_level, text=self.vrn_sv).grid(row=3, column=1)
    entry_vin = tk.Entry(
            self.top_level, text=self.vin_sv).grid(row=4, column=1)
    info_label = tk.Label(self.top_level, textvariable=self.info_sv,
                              font=12, padx=10, pady=10, fg='red').grid(row=5, column=0, columnspan=2)

Etykieta info_label służy do wyświetlenia komunikatu w razie nie wypełnienia wszystkich wymaganych pól do utworzenia nowego auta.

        save_button = tk.Button(self.top_level, text='Save',
                                command=self.save_new_car)
        save_button.bind('<Return>', self.save_new_car)
        save_button.grid(row=6, column=1, sticky='W', padx=10)
        cancel_button = tk.Button(self.top_level, text='Cancel',
                                  command=self.top_level.destroy)
        cancel_button.grid(row=6, column=1, sticky='W', padx=70)
        cancel_button.bind('<Return>', self.top_level.destroy)

Na dole okna wyświetlone zostają przyciski Save oraz Cancel do zapisania danych o nowym aucie w bazie danych lub zamknięcia okna 'Add car’.

def save_new_car(self, event=None):
    if self.make_sv.get() and self.model_sv.get() and self.year_sv.get() and self.vrn_sv.get() and self.vin_sv.get():
        self.helper.add_car(self.make_sv.get(),
                            self.model_sv.get(),
                            self.year_sv.get(),
                            self.vrn_sv.get(),
                            self.vin_sv.get())
        self.top_level.destroy()
        self.parent.show_cars()
    else:
        self.info_sv.set('Please fill in all entry fields')

Kod metody save_new_car() jest aktywowany, jeśli naciśnięto przycisk Save. Jeśli wypełnione zostały wszystkie wartości potrzebne do utworzenia nowego auta to zostaje uruchomiona metoda add_car() klasy Helper, a następnie zamykane jest okno potomne i aktualizowane są wpisy o autach w oknie głównym za pomocą metody show_cars() klasy CarManager.

Okno potomne 'Add car’. Komunikat wyświetla się, gdy nie wypełnione wszystkie pola wprowadzania. Focus ustawiony na pierwszym elemencie.

***

Kod klasy RepairsWindow, aktywowany po naciśnięciu przycisku repairs_button okna głównego:

import tkinter as tk
from tkinter import ttk
from sql_helper import Helper
from date_picker import DatePicker


class RepairsWindow():
    def __init__(self, root, car):
        self.top_level = tk.Toplevel(root)
        self.root = root
        self.top_level.title('Repairs')
        self.top_level.grab_set()
        self.car = car
        self.helper = Helper()

        x = self.top_level.winfo_screenwidth()
        y = self.top_level.winfo_screenheight()
        geometry = '+{}+{}'.format(int((x / 2) - 150),
                                   int((y / 2) - 150))
        size = self.top_level.geometry(geometry)

Na początku importuję wszystkie niezbędne klasy. W metodzie __init__() tworzone jest nowe okno – egzemplarz klasy TopLevel, ustawiany jest tytuł okna na 'Repairs’. Następnie za pomocą metody grab_set() blokowana jest możliwość uruchamiania widżetów z okna głównego. Ustawiane są zmienne self.car i self.helper, a następnie dokonywane jest pozycjonowanie okna potomnego względem okna głównego.

        toolbox_frame = tk.Frame(self.top_level)
        toolbox_frame.grid(column=0, row=0, sticky='W')
        self.add_repair_img = tk.PhotoImage(file='Resources/add_repair.gif')
        add_repair_button = tk.Button(
            toolbox_frame, image=self.add_repair_img, command=self.add_repair)
        add_repair_button.grid(column=0, row=0, sticky='W')
        add_repair_button.bind('<Return>', self.add_repair)
        add_repair_button.bind('<KP_Enter>', self.add_repair)
        if car.sold:
            add_repair_button.config(state='disabled')

W oknie potomnym tworzona jest ramka toolbox_frame, w której umieszczony zostaje przycisk add_repair_button. Do utworzonego przycisku zostaje dowiązana metoda add_repair() – aktywowana za pomocą myszki lub klawiatury po naciśnięciu Enter na klawiaturze numerycznej lub głównej. Sprawdzane jest następnie, czy auto nie jest oznaczone jako 'sprzedane’, wtedy przycisk umożliwiający dodawanie nowych napraw jest nieaktywny.

        col_headers = ('No', 'Date', 'Description')
        self.repairs_tv = ttk.Treeview(self.top_level, columns=col_headers,
                                       show='headings', selectmode='none')
        self.repairs_tv.tag_configure('c1', background='ivory2')
        self.repairs_tv.tag_configure('c2', background='ivory3')
        for i, col in enumerate(col_headers):
            self.repairs_tv.heading(col, text=col)
            self.repairs_tv.column(col, anchor='center')
            if i == 0:
                self.repairs_tv.column(col, width=50, stretch='NO')
        self.repairs_tv.grid(column=0, row=2,  sticky='NSWE')

        scrollbar = tk.Scrollbar(self.top_level, command=self.repairs_tv.yview)
        scrollbar.grid(column=1, row=2, sticky='NS')
        
        self.show_repairs()

Kolejny fragment kodu opisuje utworzenie widżetu Treeview, w którym będą wyświetlane dane o naprawach. Dla poprawienia czytelności utworzyłem dwa tagi, które ustawiają kolory tła wpisywanych elementów widżetu. Metoda __init__() kończy się wywołaniem metody show_repairs(), która dodaje elementy o naprawach do widżetu Treeview.

    def show_repairs(self):
        repairs = self.helper.show_repairs(self.car)
        self.repairs_tv.delete(*self.repairs_tv.get_children())
        for i, repair in enumerate(repairs, start=1):
            repair = (i, repair[0], repair[2])
            if i % 2:
                self.repairs_tv.insert('', 'end', values=repair, tag='c1')
            else:
                self.repairs_tv.insert('', 'end', values=repair, tag='c2')

W metodzie show_repairs() wywoływana jest odpowiednia metoda klasy Helper, która pobiera dane o naprawach z bazy danych. Następnie kasowane są elementy repairs_tv i ustawiane są zaktualizowane dane. Dla poprawienia czytelności kolejne wiersze są naprzemiennie kolorowane tzn.

Kod metody add_repair, który jest aktywowany, gdy naciśnięto przycisk add_repair_button za pomocą myszki lub z klawiatury:

    def add_repair(self, event=None):
        self.add_repair_frame = tk.Frame(self.top_level)
        self.add_repair_frame.grid(
            column=0, row=1, pady=20, sticky='WE')

        date_label = tk.Label(self.add_repair_frame,
                              text='Date:').grid(column=0, row=2)
        self.date_sv = tk.StringVar()
        self.date_entry = tk.Entry(self.add_repair_frame,
                                   text=self.date_sv)
        self.date_entry.focus_set()
        self.date_entry.grid(column=1, row=2, sticky='W')

        self.cal_img = tk.PhotoImage(file='Resources/calendar.gif')
        show_cal_btn = tk.Button(self.add_repair_frame, image=self.cal_img,
                                 command=self.show_cal, relief='flat').grid(column=1, row=2, sticky='W', padx=170)

        description_label = tk.Label(self.add_repair_frame,
                                     text='Description:').grid(column=0, row=3)
        self.description_sv = tk.StringVar()
        self.description_entry = tk.Entry(self.add_repair_frame,
                                          text=self.description_sv)
        self.description_entry.grid(column=1, row=3, ipadx=200)
        save_button = tk.Button(self.add_repair_frame, text='Save',
                                command=self.save_repair)
        save_button.grid(column=1, row=4, pady=10, sticky='E')
        save_button.bind('<Return>', self.save_repair)
        save_button.bind('<KP_Enter>', self.save_repair)
        cancel_button = tk.Button(self.add_repair_frame, text='Cancel',
                                  command=self.cancel_repair)
        cancel_button.grid(column=1, row=4, sticky='E', padx=60)
        cancel_button.bind('<Return>', self.cancel_repair)
        cancel_button.bind('<KP_Enter>', self.cancel_repair)

W metodzie add_repair() tworzona jest ramka w której umieszczone są pola etykiet i pola wprowadzania danych o dacie naprawy i opisie naprawy. Focus ustawiany jest na pole wpisywania daty – date_entry. Obok pola wprowadzania daty jest umieszczony przycisk do aktywowania obiektu date picker. Metoda zawiera jeszcze deklarację dwóch przycisków: cancel_button oraz save_button służące do zapisu wpisu o naprawie lub zamknięcia ramki dodawania wpisu tzn.

new repair
Okno Repairs po wciśnięciu przycisku add_repair_button

Kod metody cancel_repair(), która chowa ramkę repair_frame:

    def cancel_repair(self, event=None):
        self.add_repair_frame.grid_remove()

Kod metody save_repair():

def save_repair(self, event=None):
        if self.date_sv.get() and self.description_sv.get():
            self.helper.add_repair(self.car, self.date_sv.get(),
                                   self.description_sv.get())
            self.show_repairs()
            self.add_repair_frame.grid_remove()

W metodzie save_repair() dokonywana jest podstawowa walidacja formularza tzn. sprawdzane jest czy wszystkie wymagane pola do utworzenia wpisu o naprawie są wypełnione. Jeśli tak, to notatka o naprawie jest zapisywana w bazie danych (metoda add_repair klasy Helper), a następnie uaktualniane są wpisy o naprawach i chowana jest ramka repair_frame.

Pozostała metoda show_cal(), która jest aktywowana po wciśnięciu przycisku show_cal_btn:

    def show_cal(self, event=None):
        date_picker = DatePicker(self.top_level, self.date_entry, '%d-%m-%Y')
        self.description_entry.focus_set()

Medoda show_cal() tworzy instancję klasy DatePicker, którą utworzyłem w celu pobierania daty i wstawiania jej do podanego pola Entry. Klasa ta jako argument umożliwia wstawienie wybranego formatowania wybranej daty, dzięki czemu np. w ustawieniach aplikacji użytkownik mógłby wybrać jak ma być wyświetlana data.

Kod klasy DatePicker opiszę w kolejnym wpisie.

date picker
My DatePicker object

P.S. Jeśli okno główne ma się uruchamiać jako zmaksymalizowane, ale z opcją zmiany rozmiaru to w metodzie __init__() klasy CarManager należy dopisać:

root.attributes('-zoomed', True)

P.P.S. Jeśli wpisy o autach mają wyświetlać się z formatowaniem wierszy to metodę show_cars() można zmodyfikować, dopisując tagi definiujące kolory wierszy tzn.

    def show_cars(self):
        all_cars = self.helper.show_all_cars()
        self.car_tview.delete(*self.car_tview.get_children())
        for i, car in enumerate(all_cars, start=1):
            car = list(car)
            car.insert(0, i)
            if i % 2:
                self.car_tview.insert('', 'end', values=car, tag='c2')
            else:
                self.car_tview.insert('', 'end', values=car, tag='c1')
            self.car_tview.tag_configure('c1', background='ivory3')
            self.car_tview.tag_configure('c2', background='ivory2')
widżet Treeview z uwzględnieniem tagów formatujących

GUI do programu CRUD cz.2

We wcześniejszym wpisie utworzyłem główne okno aplikacji. Następnie uzupełnię metody, które obsługują zdarzenia inicjowane przez elementy interfejsu.

Najpierw uzupełnię metodę show_cars(), która jest aktywowana po wczytaniu widgetów do okna głównego jak również każdorazowo np. po dodaniu lub usunięciu auta z bazy.

    def show_cars(self):
        all_cars = self.helper.show_all_cars()
        self.car_tview.delete(*self.car_tview.get_children())
        for i, car in enumerate(all_cars, start=1):
            car = list(car)
            car.insert(0, i)
            self.car_tview.insert('', 'end', values=car)

Na początku uruchamiana jest metoda show_all_cars() klasy Helper. Zwraca ona wszystkie auta z bazy danych w postaci listy krotek. Następnie usuwane są wszystkie elementy widżetu TreeView, aby wczytać ponownie listę aut po zmianach. Do każdego elementu dodawana jest liczba porządkowa.

Aby ułatwić obsługę metod del_car(), sell_car(), show_repairs() wyodrębnię prywatną metodę: __get_car_from_selection(), która zwraca instancję klasy Car na podstawie zaznaczonego wiersza tzn.

    def __get_car_from_selection(self):
        selected_item = self.car_tview.focus()
        if selected_item == '':
            messagebox.showinfo('Information', 'No item selected')
        else:
            selection_dict = self.car_tview.item(selected_item)
            selection = selection_dict.get('values')
            i, *args = selection
            car = Car.from_list(args)
            return car

Na początku w powyższej metodzie sprawdzane jest, czy zaznaczony jest jakiś wiersz zawierający dane o samochodzie. Jeśli tak, to zwracany jest element obiektu TreeView w formie słownika. Dane o wybranym aucie obecne są jako wartość dla klucza 'values’. Z listy usuwany jest pierwszy element, będący liczbą porządkową wiersza. Tak zmodyfikowana lista jest argumentem metody from_list() klasy Car.

Kod metody del_car():

    def del_car(self):
        car = self.__get_car_from_selection()
        if car and messagebox.askyesno('Delete', 'Delete selected car?'):
            self.helper.del_car(car)
            self.show_cars()

W powyższej metodzie sprawdzane jest, czy zmienna car nie jest typu None i czy również użytkownik potwierdził w oknie dialogowym usuwanie danych o aucie. Jeśli oba te warunki są spełnione to zostaje uruchomiona metoda klasy Helper kasująca auto z bazy danych. Następnie uaktualniane są wpisy o autach w bazie danych za pomocą metody show_cars() klasy CarManager.

Kod metody sell_car():

    def sell_car(self):
        car = self.__get_car_from_selection()
        if car and car.sold:
            messagebox.showinfo('Information', 'Already marked as sold')
        elif car and messagebox.askyesno('Sell', 'Mark car as sold?'):
            self.helper.set_sold(car)
            self.show_cars()

W powyższej metodzie sprawdzane jest czy auto nie zostało już wcześniej oznaczone jako sprzedane. Jeśli nie to wyświetlony zostaje okno dialogowe w celu potwierdzenia. Jeśli użytkownik zaakceptuje, to wywoływana jest metoda set_sold() klasy Helper a następnie metoda show_cars() obiektu CarManager.

Kod metody show_repairs():

    def show_repairs(self):
        car = self.__get_car_from_selection()
        if car:
            repairs = RepairsWindow(self.root, car)

W metodzie show_repairs(), gdy zmienna car nie jest typu None zostaje utworzone nowe okno potomne, które opisuje klasa RepairsWindow.

Ostatnią opisaną w tym wpisie metodą jest metoda add_car(), która jest aktywowana po naciśnięciu przycisku add_car_button.

def add_car(self):
        add_car = NewCarWindow(self.root, self)

W metodzie add_car() zostaje utworzone nowe okno potomne, które opisuje klasa NewCarWindow.

Kody obu klas opiszę w kolejnym wpisie.

Dopisania wymagają jeszcze metody dowiązane do pola wprowadzania search_entry tzn. on_entry_in(), on_entry_out() i on_entry_return(), tzn.

    def on_entry_in(self, event):
        self.search_entry.config(fg='black')
        self.search_variable.set('')

    def on_entry_out(self, event):
        self.search_entry.config(fg='grey')
        self.search_variable.set('Search car by VRN')

Uzyskanie focusa przez pole wyszukiwania search_entry powoduje wyczyszczenie pola wyszukiwania i zmianę koloru czcionki. Strata focusa powoduje wypisanie w polu search_entry szarego napisu 'Search car by VRN’.

def on_entry_return(self, event):
    vrn = self.search_variable.get()
    if vrn == '':
        self.show_cars()
    else:
        car = self.helper.search_by_vrn(vrn)
        self.car_tview.delete(*self.car_tview.get_children())
        if car:
            car = list(car)
            car.insert(0, 1)
            self.car_tview.insert('', 'end', values=car)

Powyższy kod metody on_entry_return sprawdza, czy po naciśnięciu klawisza Enter pole wyszukiwania jest puste. Jeśli tak, to wyświetlane są dane o wszystkich dostępnych autach z bazy. Jeśli w polu wyszukiwania jest wpisana jakaś fraza, to zostaje uruchomiona metoda search_by_vrn() klasy Helper, a następnie uaktualniona zawartość widżetu car_tview, wyświetlająca wyniki wyszukiwania.

GUI do programu CRUD cz.1

W poprzednim wpisie opisałem prosty program CRUD, który modyfikował bazę danych (w tym przypadku SQLite). Do utworzonego wcześniej programu utworzę gui w bibliotece tkinter. Interfejs będzie rozwijany stopniowo, dzięki czemu łatwiej będzie wytłumaczyć zastosowane metody.

Kod programu można pobrać tutaj.

Utworzone we wcześniejszym wpisie pliki nieznacznie zmodyfikuję tzn. do pliku opisującego klasę Car() dopiszę metodę klasy, która tworzy instancję, gdy jako argument podano listę. Ponadto w klasie Helper() metoda del_car() oprócz usuwania auta z bazy danych, usuwa również wszystkie dane o wpisanych naprawach związanych z tym autem. W produkcyjnej bazie danych lepszym rozwiązaniem byłoby zapewne oznaczenie auta jako „skasowanego” zamiast faktycznego kasowania z bazy.

Kod dopisanych fragmentów wygląda następująco:

class Car():
    @classmethod
    def from_list(cls, list):
        make, model, year, vrn, vin, sold = list
        return cls(make, model, year, vrn, vin, sold)

class Helper():
    def del_car(self, car):
        with self.conn:
            self.c.execute("SELECT ROWID FROM cars WHERE vin=:vin",
                           {'vin': car.vin})
            car_id = self.c.fetchone()
            self.c.execute("DELETE FROM repairs WHERE car=?",
                           (car_id))
            self.c.execute("DELETE FROM cars WHERE vin=:vin", {'vin': car.vin})

Kod pliku car_manager.py zawierający klasę CarManager() opisującą graficzny interfejs.

import tkinter as tk
from tkinter import messagebox, ttk
from sql_helper import Helper
from car import Car

class CarManager(tk.Frame):
    def __init__(self, root):
        super().__init__(root)
        self.root = root
        self.helper = Helper()
        root.title('Car manager')
        root.iconphoto(True, tk.PhotoImage(file='Resources/car_logo.png'))
        root.bind('<Control-x>', self.close_app)
        root.columnconfigure(1, weight=1)
        root.rowconfigure(2, weight=1)
        self.create_widgets()
        self.show_cars()


if __name__ == '__main__':
    root = tk.Tk()
    cm = CarManager(root)
    cm.mainloop()

Podczas tworzenia egzemplarza klasy CarManager tworzę instancje klasy pomocniczej Helper, ustawiam nazwę tworzonego okna na 'Car Manager’, dodaję ikonkę programu z katalogu Resources. Oprócz tego ustawiam skrót klawiszowy do zakończenia programu (Ctrl+x). Następnie ustawiany jest sposób w jaki mają być skalowane widgety podczas zmiany rozmiaru okna. Ostatnie dwa wiersze metody __init__() uruchamiają metody odpowiedzialne za utworzenie wszystkich widgetów okna głównego oraz za wyświetlenie danych z bazy danych o zapisanych autach.

Kod metody close_app() uruchamianej podczas naciśnięcia skrótu (Ctrl+x):

    def close_app(self, event):
        result = tk.messagebox.askyesno('Exit', 'Close application?')
        if result:
            self.root.destroy()

Jeśli rezultat jest wartością True tzn. użytkownik wybrał przycisk potwierdzający zamknięcie aplikacji to okno główne aplikacji jest zamykane.

Kod metody create_widgets() odpowiedzialnej za wyświetlenie elementów interfejsu:

def create_widgets(self):
        # create menu
        menubar = tk.Menu(self.root)
        self.root.config(menu=menubar)
        file_menu = tk.Menu(menubar, tearoff=0)
        file_menu.add_command(label='Exit', command=exit, accelerator="Ctrl+x")
        menubar.add_cascade(label="File", menu=file_menu)

        # create toolbar
        tbar_frame = tk.Frame(root, height=10)
        tbar_frame.grid(row=0, column=0)

        self.add_car_img = tk.PhotoImage(file='Resources/add_car.gif')
        self.remove_car_img = tk.PhotoImage(file='Resources/remove_car.gif')
        self.repairs_img = tk.PhotoImage(file='Resources/repairs.gif')
        self.sold_img = tk.PhotoImage(file='Resources/sold.gif')

        add_car_button = tk.Button(tbar_frame, image=self.add_car_img,
                                   command=self.add_car).grid(row=0, column=0, sticky='W')
        del_car_button = tk.Button(tbar_frame, image=self.remove_car_img,
                                   command=self.del_car).grid(row=0, column=0, sticky='W', padx=30)
        repairs_button = tk.Button(tbar_frame, image=self.repairs_img,
                                   command=self.show_repairs).grid(row=0, column=0, sticky='W', padx=60)
        sold_button = tk.Button(tbar_frame, image=self.sold_img,
                                command=self.sell_car).grid(row=0, column=0, sticky='W', padx=90)

        # create search entry
        self.search_variable = tk.StringVar()
        self.search_entry = tk.Entry(
            root, textvariable=self.search_variable)
        self.search_entry.config(fg='grey')
        self.search_variable.set("Search car by VRN")
        self.search_entry.bind('<FocusIn>', self.on_entry_in)
        self.search_entry.bind('<FocusOut>', self.on_entry_out)
        self.search_entry.bind('<Return>', self.on_entry_return)
        self.search_entry.bind('<KP_Enter>', self.on_entry_return)
        self.search_entry.grid(row=0, column=1, sticky='E', ipadx=20)

        # create TreeView with Scrollbar
        col_headers = ('No', 'Make', 'Model', 'Year', 'VRN', 'VIN', 'Sold')
        self.car_tview = ttk.Treeview(self.root, columns=col_headers,
                                      show='headings', selectmode='browse')
        self.car_tview.columnconfigure(0, weight=1)
        self.car_tview.rowconfigure(0, weight=1)
        # set column headers
        for i, col in enumerate(col_headers):
            self.car_tview.heading(col, text=col)
            self.car_tview.column(col, anchor='center')
            if i == 0:
                self.car_tview.column(col, width=50, stretch='NO')
        self.car_tview.grid(row=2, column=0, columnspan=2, sticky='NSWE')

        scrollbar = tk.Scrollbar(self.root, command=self.car_tview.yview)
        scrollbar.grid(column=3, row=2, sticky='NS')

W metodzie create_widgets() kolejno tworzone jest menu programu, następnie pasek narzędziowy zawierające przyciski dodawania auta do bazy, usuwania auta, dodawania danych o naprawach auta oraz oznaczania sprzedaży auta. Następnie tworzone jest pole wyszukiwania auta po numerze rejestracyjnym, do którego podpięte są metody, które reagują na zdarzenia uzyskania/stracenia focusa oraz zatwierdzania danych przez wciśnięcie klawisza ENTER na: klawiaturze numerycznej (<KP_Enter>) lub podstawowej (<Return>). Kolejne linie definiują widget TreeView, w którym będą przedstawiane kolejne wiersze zawierające dane o autach oraz pasek przewijania, jeśli dane nie mieszczą się na stronie.

Po zmianach główne okno programu powinno wyglądać następująco:

Car Manager 1
Początkowe okno programu. Na razie bez danych…

>>część druga<<

Python i SQLite

Python zawiera moduł sqlite3 w bibliotece standardowej. Aby przedstawić możliwości biblioteki sqlite3 przedstawię prostą aplikację CRUD, do obsługi pojazdów i rejestracji napraw.

Na początek utworzę dwa pliki pomocnicze: car.py i cars_sql_scheme.py. Pierwszy z nich będzie definiował klasę Car – opisującą auto, a drugi będzie definiował schemat bazy danych (a w zasadzie zapytania tworzące dwie tabele w bazie danych).

plik: car.py

class Car():
    """Represents a sample car.

    Arguments:
    make - car make e.g. Honda
    model - car model e.g. Civic
    year - year of production
    vrn - vehicle registration number
    vin - VIN number
    sold - if car is still our property 

    """

    def __init__(self, make, model, year, vrn, vin, sold=False):
        self.make = make
        self.model = model
        self.year = year
        self.vrn = vrn
        self.vin = vin
        self.sold = sold

plik cars_sql_scheme.py

create_table_cars = """CREATE TABLE IF NOT EXISTS cars(
	make TEXT,
	model TEXT,
	year TEXT,
	vrn TEXT,
	vin TEXT,
	sold INTEGER
	)"""

create_table_repairs = """CREATE TABLE IF NOT EXISTS repairs(
	date TEXT,
	car INTEGER,
	description TEXT,
	FOREIGN KEY(car) REFERENCES cars(ROWID)
	)"""

W trzecim pliku sql_helper.py definiuję klasę Helper, która będzie zawierać metody:

  • add_car() – dodanie auta do bazy danych
  • del_car() – usuwa auto z bazy
  • search_by_vrn() – wyszukuje auto po numerze rejestracyjnym
  • show_all_cars() – zwraca wszystkie auta w bazie
  • set_sold() – aktualizuje dane auta, gdy auto zostało sprzedane
  • add_repair() – dodaje adnotację o naprawie auta
  • show_repairs() – wyświetla wszystkie naprawy auta

Skrypt importuje moduł sqlite3, klasę Car z pliku car.py oraz zmienne create_table_cars i create_table_repairs z pliku cars_sql_scheme.py tzn.

import sqlite3
from car import Car
from cars_sql_scheme import create_table_cars, create_table_repairs

class Helper():
    def __init__(self):
        self.conn = sqlite3.connect('cars.db')
        self.c = self.conn.cursor()
        self.c.execute(create_table_cars)
        self.c.execute(create_table_repairs)

Metoda __init__() stwarza połączenie z bazą o nazwie cars.db, tworzy kursor do obsługi zapytań oraz tworzy tabele cars i repairs (jeśli nie są już utworzone).

Metoda add_car() jako argumenty pobiera markę pojazdu, model, rok produkcji, nr rejestracyjny i numer VIN. Funkcja zwraca nową instancję klasy Car. Dodanie nowego samochodu do bazy jest przeprowadzone przy użyciu menadżera kontekstu, dzięki czemu automatycznie zatwierdzana jest transakcja (nie trzeba wpisywać commit() po każdej zmianie bazy) tzn.

def add_car(self, make, model, year, vrn, vin):
    	"""Adds new car to database.

    	Arguments:
    	make - car make e.g. Honda
    	model - car model e.g. Civic
    	year - year of production
    	vrn - vehicle registration number
    	vin - VIN number

    	Returns:
    	new Car instance
    	"""
        with self.conn:
            self.c.execute("INSERT INTO cars VALUES (:make, :model, :year, :vrn, :vin, :sold)", {
                'make': make, 'model': model, 'year': year, 'vrn': vrn, 'vin': vin, 'sold': False})
            return Car(make, model, year, vrn, vin)

Analogicznie metoda del_car() wygląda następująco:

def del_car(self, car):
    	"""Deletes car from database.

    	Arguments:
    	car  - car instance

    	Returns:
    	None
    	"""
        with self.conn:
            self.c.execute("DELETE FROM cars WHERE vin=:vin", {'vin': car.vin})

Wyszukiwanie nie wymaga zatwierdzania transakcji, więc nie stosuję menadżera kontekstu tzn.

def search_by_vrn(self, vrn):
        """Search car by vehicle registration number.

        Arguments:
        vrn  - vehicle registration number

        Returns:
        search result tuple
        """
        self.c.execute("SELECT * FROM CARS WHERE vrn=:vrn", {'vrn': vrn})
        return self.c.fetchone()

    def show_all_cars(self):
    	"""Search availale cars.

        Returns:
        search result  - list of tuples
        """
        self.c.execute("SELECT * FROM CARS")
        return self.c.fetchall()

Funkcja set_sold() ustawia kolumnę sold w bazie danych na wartość True, reprezentowaną przez wartość równą 1.

def set_sold(self, car):
    	"""Mark car as sold.

        Arguments:
        car  - Car instance

        Returns:
        None
        """
        car.sold = True
        with self.conn:
            self.c.execute("UPDATE cars SET sold=True WHERE 
              vin=:vin",'vin': car.vin})

Ostatnie dwie metody umożliwiają dodanie notatki o naprawie auta oraz wyświetlenie listy napraw. tzn.

    def add_repair(self, car, date, description):
    	"""Adds repair note.

        Arguments:
        car  - Car instance
        date  - repair date
        description  - repair description

        Returns:
        None
        """
        self.c.execute("SELECT ROWID FROM cars WHERE vin=:vin",
                       {'vin': car.vin})
        car_id = self.c.fetchone()[0]
        with self.conn:
            self.c.execute("INSERT INTO repairs VALUES (:date, :car, :description)", {
                'date': date, 'car': car_id, 'description': description})
def show_repairs(self, car):
        """Shows car repairs notes.

        Arguments:
        car  - Car instance

        Returns:
        search result  - list of tuples
        """
        self.c.execute("SELECT ROWID FROM cars WHERE vin=:vin",
                       {'vin': car.vin})
        car_id = self.c.fetchone()
        self.c.execute("SELECT * FROM repairs WHERE car=?",
                       (car_id))
        return self.c.fetchall()

Podstawy biblioteki venv

Biblioteka venv obecna w jako moduł wbudowany począwszy od pythona w wersji 3.3 umożliwia tworzenie wirtualnych środowisk.

Środowisko wirtualne umożliwia wykorzystanie konkretnych wersji bibliotek, niezależnie od tych, które są zainstalowane w domyślnej lokalizacji np. /usr/lib/python3.7/site-packages. Zapobiega to wystąpieniu błędów, gdy nowa, zaktualizowana wersja używanej biblioteki źle współpracuje z resztą projektu (projekt wymaga wcześniejszej wersji).

Aby utworzyć środowisko wirtualne dla bieżącego projektu należy wpisać:

python -m venv project_name/env_name

W katalogu projektu o nazwie project_name zostaje utworzony katalog zawierający wirtualne środowisko o nazwie env_name. Katalog środowiska wirtualnego zawiera kilka katalogów, m.in. katalog /bin zawierający dowiązanie do interpretera pythona, jak również plik activate służący do uruchomienia środowiska. Oprócz tego w katalogu env_name jest również katalog /lib zawierający zainstalowane moduły (site-packages).

Aby uruchomić utworzone środowisko o nazwie env_name należy wpisać w shellu:

source project_name/env_name/bin/activate

Środowisko zamykamy za pomocą polecenia deactivate.

Gdy mamy uruchomione środowisko wirtualne, to nowo instalowane biblioteki są dostępne tylko w wybranym środowisku wirtualnym. Można zapisać wymagane dla projektu biblioteki w pliku tekstowym za pomocą:

pip freeze > requirements.txt

W przypadku, gdy mamy plik requirements.txt opisujący wymagania projektu co do bibliotek i ich wersji to można doinstalować je za pomocą:

pip install -r requirements.txt

Uruchomienie programu ze skryptu

Aby w Pythonie uruchomić inny program, możemy w tym celu użyć funkcję system() z modułu os. Problem jest w przypadku, jeśli chcemy odczytać rezultat działania uruchomionego programu, tzn.

import os
result = os.system('df -h')
print(result)

Powyższy program wyświetli w konsoli wynik działania programu df, ale wartością zmiennej result jest kod błędu wykonania funkcji os.system(). Jeżeli chcemy, aby zmienna result przechowywała rezultat zwracany przez program, to zamiast funkcji system() należy użyć funkcję popen() lub jej nowszy odpowiednik – klasę Popen z modułu subprocess. tzn.

# 1 first option - function popen()
import os
result = os.popen('df -h').read()
print(result)

# 2 second option - Popen class
import subprocess
command = subprocess.Popen('df -h', shell=True, stdout=subprocess.PIPE)
result = command.stdout.read().decode('utf-8')
print(result)

Parsowanie pliku CSV

Pliki csv są plikami tekstowymi, w których każdy wiersz reprezentuje jeden rekord danych, a poszczególne dane w wierszu są przedzielone delimiterem, najczęściej znakiem przecinka.

W poniższym przykładzie parsujemy plik raportu tankowań ze stacji paliwowej. Pierwszy wiersz jest nagłówkiem i zawiera dane: Dane Kontrahenta;Imie;Nazwisko;Numer Korekty;Numer WZ;Data;Godzina;Licznik;Stacja;Numer Rejestracyjny;Numer Karty;Nazwa towaru;VAT procent;Cena na stacji;Cena netto;Cena brutto;Wartość rabatu;Ilość;Netto;VAT;Brutto. W tym konkretnym przypadku delimiterem jest znak średnika. Kolejne wiersze będą zawierały wpisy o kolejnych tankowaniach. Chcemy uzyskać z pliku źródłowego dane o dacie tankowania, numerze rejestracyjnym auta i pobraną ilość litrów paliwa.

import csv

with open('raport.csv') as csv_file:
    csv_reader = csv.DictReader(csv_file, delimiter=';')
    total = 0
    for line in csv_reader:
        print('{}  {}  {} ltr'.format(
            line['Data'], line['Numer Rejestracyjny'], line['Ilość']))

        total += float((line['Ilość']).replace(',', '.'))
    print('Total: ', total, 'ltr')

    with open('NOWE_ZESTAWIENIE.csv', 'w') as new_csv_file:
        field_names = ['Data', 'Auto', 'Tankowanie']
        csv_writer = csv.DictWriter(
            new_csv_file, fieldnames=field_names, delimiter=';')
        csv_writer.writeheader()
        csv_file.seek(0)
        next(csv_reader)
        for line in csv_reader:
            dict = {}
            dict['Data'] = line['Data']
            dict['Auto'] = line['Numer Rejestracyjny']
            dict['Tankowanie'] = line['Ilość']
            csv_writer.writerow(dict)

Parsowanie dokonujemy przy użyciu modułu csv. Następnie używając menadżera kontekstu otwieramy do odczytu plik raport.csv ze stacji paliwowej. Do odczytu używamy obiekt DictReader, dzięki czemu będzie można odwoływać się do wartości poprzez podanie kluczy z nagłówka pliku csv. Następnie obliczona jest wartość total – całkowita ilość pobranego paliwa w okresie objętym w raporcie (poszczególne dane o tankowaniach są rzutowane na wartość float, przy czym polski zapis części dziesiętnej z przecinkiem jest zamieniany na zapis z kropką).

Pozyskane dane o tankowaniach zapisujemy w pliku NOWE_ZESTAWIENIE.csv. W tym przypadku używamy obiekt DictWriter. Aby jeszcze raz skorzystać z iteratora należy ustawić wskaźnik zawartości pliku na początek pliku – csv_file.seek(0). Domyślne nagłówki zastępujemy nowymi, zawartymi w liście field_names, dlatego konieczne jest przeskoczenie do nowej wartości iteratora poprzez wykonanie next(csv_reader). Nowy wiersz zapisujemy w pliku za pomocą metody writerow() obiektu csv.DictWriter.