Daj Się Poznać 2017, Programowanie, Python, TaskList

Refaktoryzacja formularza rejestracji

Jak już jesteśmy w temacie refaktoryzacji, to zajmiemy się tym razem formularzem rejestracji użytkowników.

Najpierw dodajmy klasę odpowiadającą za obsługę formularza:

<pre>class RegisterUserForm(forms.ModelForm):

    password = forms.CharField(widget=forms.PasswordInput(), label='Hasło')
    confirm_password = forms.CharField(widget=forms.PasswordInput(), label='Powtórz hasło')

    def __init__(self, *args, **kwargs):
        super(RegisterUserForm, self).__init__(*args, **kwargs)

    class Meta:
        model = User
        fields = ['username', 'password']

    def clean(self):
        username = self.cleaned_data.get('username')
        password = self.cleaned_data.get("password")
        conf_password = self.cleaned_data.get("confirm_password")

        if password != conf_password:
            raise forms.ValidationError(_("Podane hasła się nie zgadzają."))

        return self.cleaned_data</pre>

Teraz przyszła pora na zmiany w klasie widoku RegisterView.

Najpierw przeniesienie nazwy szablonu do pola template_name, potem zastąpienie wywołania metody render polem success_url. Jeszcze dołóżmy form_class i dwie metody: get_context_data oraz form_valid i będzie to:

class RegisterView(CreateView):
    template_name = "TaskList/register.html"
    form_class = RegisterUserForm
    success_url = reverse_lazy("login")

    def get_context_data(self, **kwargs):
        context_data = super(RegisterView, self).get_context_data(**kwargs)
        context_data['user'] = None
        return context_data

    def form_valid(self, form):
        form_data = form.clean()

        try:
            User.objects.get(username__exact=form_data['username'])
            form.add_error('username', _('Użytkownik o tej nazwie już istnieje.'))
            return super(RegisterView, self).form_invalid(form)

        except ObjectDoesNotExist:
            # Wykonuj, jeżeli nie ma takiego użytkownika (happy path)
            user = User(username=form_data['username'])
            user.set_password(form_data['password'])
            user.save()
            return super(RegisterView, self).form_valid(form)

I mamy mniej kodu odpowiadającego za rejestrację nowych użytkowników. Ale jest jedna zmiana: zamiast używać chronionego pola _errors jest metoda add_error, która służy do dodawania dodatkowych błędów formularza.

Daj Się Poznać 2017, Programowanie, Python, TaskList

Refaktoryzacja formularza zmiany hasła

Klasa obsługująca zmianę formularza jest trochę duża. Dlatego czas ją odchudzić. Jak? Zobaczycie.

Najpierw wydzielmy do osobnej klasy obsługę pól. Będzie to w klasie ChangePasswordForm.

Klasa wygląda tak:

<pre>class ChangePasswordForm(forms.Form):

    password = forms.CharField(widget=forms.PasswordInput(), label='Hasło')
    confirm_password = forms.CharField(widget=forms.PasswordInput(), label='Powtórz hasło')

    def __init__(self, *args, **kwargs):
        super(ChangePasswordForm, self).__init__(*args, **kwargs)

    def clean(self):
        password = self.cleaned_data.get("password")
        conf_password = self.cleaned_data.get("confirm_password")

        if password != conf_password:
            raise forms.ValidationError(_("Podane hasła się nie zgadzają."))

        return self.cleaned_data</pre>

Co jest takiego w tej klasie? Dwa pola z hasłem i potwierdzeniem hasła, konstruktor oraz metoda clean. Metoda clean jest najważniejsza, bo w tej metodzie sprawdzane są dwa podane hasła. Jeżeli jest różnica, zostaje zwrócony błąd walidacji „Podane hasła się nie zgadzają.”

Następnie zmieniłem klasę bazową dla ChangePasswordView na FormView. Dzięki temu mogę wyciąć logikę związaną z renderowaniem szablonu do innej klasy i ograniczyć liczbę linii tej klasy.

No to jedziemy z koksem.

Metody get i post zostały wycięte. Wystarczyła już gdzie indziej użyta metoda get_context_data do przekazania do szablonu nazwy zalogowanego użytkownika.

Następnie użyłem metody form_valid do walidacji formularza i zapisania zmienionego hasła użyktownika. Tutaj jest ciekawa rzecz, bo pobieram nazwę zalogowanego użytkownika, sprawdzam, czy jest taki użytkownik i sprawdzam, czy podane hasło jest takie samo jak wcześniej. Jeśli tak, dopisuję komunikat o podaniu tego samego hasła do listy błędów i zwracam wynik metody form_invalid. Dzięki temu nie następuje zatwierdzenie zmiany hasła, chociaż 2 razy wpisano hasło.

Do jego obsługi wykorzystałem chronione pole _errors, tak jak to pokazano w tym snippecie (wycinku).

Do pobrania hasła wykorzystuję metodę clean z klasy formularza i pobieram wartość pola password.

Jeżeli nie ma błędów, zapisuję nowe hasło użytkownikowi i następuje zalogowanie użytkownika z nowym hasłem.

Efekt końcowy w tej klasie jest taki:

<pre>@method_decorator(login_required, 'dispatch')
class ChangePasswordView(FormView):

    template_name = "TaskList/change_password.html"
    form_class = ChangePasswordForm
    success_url = reverse_lazy('user_page')

    def get_context_data(self, **kwargs):
        '''
        zwraca kontekst danych widoku
        :param kwargs: zmienne kontekstu
        :return: kontekst widoku
        '''
        context = super(ChangePasswordView, self).get_context_data(**kwargs)
        context['user'] = self.request.user
        return context

    def form_valid(self, form):
        user_name = self.request.user
        user = User.objects.get(username__exact=user_name)

        cleaned_data = form.clean()
        new_password = cleaned_data['password']

        # sprawdzanie, czy podano obecne hasło jako nowe
        if user.check_password(new_password):
            # dostęp do chronionego pola z błędami w formularzu
            # rozwiązanie z https://gist.github.com/vero4karu/3b62a13bdce7fe4178ac
            form._errors[NON_FIELD_ERRORS] = ErrorList([
                'Podano obecne hasło'
            ])
            return self.form_invalid(form)

        user.set_password(new_password)
        user.save()

        # ponowny login po zmianie hasła
        login(self.request, user)

        return super(ChangePasswordView, self).form_valid(form)</pre>

A teraz czas na inne zadania…

Daj Się Poznać 2017, TaskList

Podsumowanie kwietnia

Kwiecień minął na poprawkach aplikacji: poprawkach formularzy, poprawce modelu, przetłumaczeniu odstępów czasu na polski, poprawkach linków akcji.

Krótkie podsumowanie wpisów z kwietnia dla osób, które nie były na bieżąco z moim blogiem:

I ruszamy z zadaniami na maj!

Daj Się Poznać 2017, Python, TaskList

Testy na Windows 10

Dotychczas testowałem aplikację tylko w środowisku GNU/Linux, więc przyszła pora na testy na Windowsie. Skoro mam Windowsa 10, przetestujmy na tym systemie.

Na systemie zainstalowany Python 3 z Django 1.11 oraz inne niezbędne do pracy z aplikacją moduły, zgodnie z readme.md.

Najpierw klonowanie repozytorium GitHub.

Po sklonowaniu próba uruchomienia. Wyrzuca błędy no module named "forms". Zastanawiam się, co jest nie tak z tym projektem. Wpadam na pomysł po lekturze pytań i odpowiedzi ze StackOverflow, że zmienię sposób importowania. Zamieniam import forms na from .forms import TaskEditForm. Sprawdzam, czy działa. Niestety nie.

Tym razem rzuca się o django bootstrap form. No to uruchamiam Wiersz poleceń (poleceniem cmd) i instalujemy magiczną komendą pip install django-bootstrap-form brakujący moduł.

Po instalacji uruchamiam jeszcze raz. Działa!

A oto efekty działania na Windowsie 10:

Windows10-tasklistTaskList szczegóły zadania Zadanie z odwróconymi datami

Jeśli chcecie jeszcze więcej wieści o projekcie, śledźcie repozytorium, ten blog i Daj Się Poznać 2017!

Daj Się Poznać 2017, Programowanie, Python, TaskList

Odstępy czasu po polsku

Jak zauważyliście, różnica między datami jeszcze nie jest tłumaczona. Dla tych, co przegapili wpis o obliczaniu czasu wykonywania zadania, małe przypomnienie, na czym polega problem:

screenshot-localhost 8000-2017-04-05-14-39-02

Skoro aplikacja jest po polsku, widać pewną niekonsekwencję. Zaraz to naprawimy.

Do podejścia do rozwiązania problemu zainspirował mnie ten wycinek na Githubie. Co dalej?

Dalej utworzę nowy moduł z funkcjami zamieniającymi timedelta na zapisany w języku polskim odstęp.

Oto moduł polish_timedelta.py:

<pre>from datetime import timedelta

DAY = {
    'singular': 'dzień',
    'plural1': 'dni',
    'plural2': 'dni'
}

MONTH = {
    'singular': 'miesiąc',
    'plural1': 'miesiące',
    'plural2': 'miesięcy'
}

YEAR = {
    'singular': 'rok',
    'plural1': 'lata',
    'plural2': 'lat'
}

HOUR = {
    'singular': 'godzina',
    'plural1': 'godziny',
    'plural2': 'godzin'
}

MINUTE = {
    'singular': 'minuta',
    'plural1': 'minuty',
    'plural2': 'minut'
}

SECOND = {
    'singular': 'sekunda',
    'plural1': 'sekundy',
    'plural2': 'sekund'
}

def _pluralize(quantity, dictionary):

    remainder = quantity % 10

    if isinstance(dictionary, dict):
        if 1 < remainder < 5:
            return dictionary['plural1']
        elif quantity == 1:
            return dictionary['singular']
        else:
            return dictionary['plural2']
    else:
        raise TypeError('Nie podano słownika')

def localize(timespan):
    if isinstance(timespan, timedelta):
        days = int(timespan.days % 365) % 30
        months = int((timespan.days % 365) / 30)
        years = int(timespan.days / 365)
        hours = int(timespan.seconds / 3600)
        minutes = int(timespan.seconds / 60) % 60
        seconds = timespan.seconds % 60

        days_str = _pluralize(days, DAY)
        months_str = _pluralize(months, MONTH)
        years_str = _pluralize(years, YEAR)
        hours_str = _pluralize(hours, HOUR)
        minutes_str = _pluralize(minutes, MINUTE)
        seconds_str = _pluralize(seconds, SECOND)

        return_string = ""

        if years != 0:
            return_string += "{} {}".format(years, years_str)
        if months != 0:
            return_string += " {} {}".format(months, months_str)
        if days != 0:
            return_string += " {} {}".format(days, days_str)
        if hours != 0:
            return_string += " {} {}".format(hours, hours_str)
        if minutes != 0:
            return_string += " {} {}".format(minutes, minutes_str)
        if seconds != 0:
            return_string += " {} {}".format(seconds, seconds_str)

        return return_string
    else:
        raise TypeError('Niepoprawny typ danych wejściowych')</pre>

Co my mamy? Dwie funkcje. Funkcja _pluralize(quantity, dictionary) dobiera odpowiednią odmianę słowa w zależności od liczby, zgodnie z zasadami w języku polskim (oraz innych językach słowiańskich): 1 – liczba pojedyncza, reszta z dzielenia przez 10 między 2 i 4 – mianownik liczby mnogiej, reszta – dopełniacz liczby mnogiej.

W metodzie localize jest składanie napisu z odstępem czasu oraz obliczanie lat, miesięcy, dni, godzin, minut i sekund od rozpoczęcia do zakończenia zadania lub aktualnej godziny.

Oto efekt:

screenshot-localhost 8000-2017-04-19-18-13-18.png

Jeśli chcecie dowiedzieć się, co będzie się działo w tym projekcie, śledźcie kategorię TaskList oraz Daj Się Poznać 2017 tego bloga.

Daj Się Poznać 2017, Programowanie, Python, TaskList

Dodatkowe poprawki w aplikacji

W TaskList są jeszcze rzeczy do poprawy. Pierwsza to wyróżnianie akcji. Na razie są zwykłymi linkami. Co z nich zrobimy? Zobaczycie już niedługo.

Druga rzecz to brak zatwierdzenia zakończenia zadania. Można nawet wykorzystać pytanie dotyczące usuwania zadań, bo na tym samym będzie polegać potwierdzenie.

Zacznijmy od wyróżnienia akcji. Teraz jest tak:

screenshot-localhost 8000-2017-04-08-20-13-01

Widzicie, że akcje są zwykłymi linkami, które mogą wprowadzić zamieszanie?

No to zmienimy ten stan rzeczy.

Każda akcja będzie miała przycisk z kodem kolorowym i obrazkowym, tak, żeby się wyróżniały.

Efekt jest poniżej.

screenshot-localhost 8000-2017-04-12-00-15-57.png

 

Jak do tego doszedłem? Klasami przycisków Bootstrapa i ikonami opakowanymi w znaczniku span.

Po szczegóły odsyłam do plików task_list.html i task_list_filt.html.

Czytaj dalej

Daj Się Poznać 2017, Programowanie, Python, TaskList

Dodatkowe walidacje modelu

Model Task (zadanie) ma możliwość zapisania daty rozpoczęcia, która jest później niż daty zakończenia. Jak wygląda taki zapis:

Najpierw tworzymy zadanie

screenshot-localhost 8000-2017-04-08-20-12-49

Zadanie się zapisało.

screenshot-localhost 8000-2017-04-08-20-13-01

Wejdźmy w szczegóły

screenshot-localhost 8000-2017-04-08-20-13-15

Co mamy? Ujemny czas trwania. Trzeba wprowadzić walidację, czy data rozpoczęcia jest wcześniej niż data zakończenia.

Za dodatkowe walidacje pól modelu odpowiada zgodnie z dokumentacją Django, metoda clean. W tej metodzie można rzucić wyjątkiem, aby zwrócić błąd. U mnie (w pliku model.py) wygląda tak:

def clean(self):
    if self.start > self.end:
        # tłumaczenie na angielski: Start date is later than end date.
        raise ValidationError(_("Data zakończenia jest wcześniej niż data rozpoczęcia."))

Dzięki temu, nie będzie możliwe zapisanie zadania z datą zakończenia wcześniejszą niż datą rozpoczęcia.

Sprawdźmy, czy się da zapisać tak jak na początku tego wpisu…

screenshot-localhost 8000-2017-04-08-23-05-09

Jak widać, już się nie da.

Jeśli chcecie dowiedzieć się, co będzie się działo w tym projekcie, śledźcie kategorię TaskList oraz Daj Się Poznać 2017 tego bloga.

Daj Się Poznać 2017, Programowanie, TaskList

Aktywny link

Jak zauważyliście wcześniej na screenshotach, nie było zaznaczonej aktualnej strony z panelu nawigacyjnego. Czas to naprawić.

Za aktywnego linka belki nawigacyjnej w Bootstrapie odpowiada klasa active elementu li.

Do renderowania szablonu z dodatkowymi zmiennymi używamy słownika context, więc ponownie zrobimy z nim użytek – dodamy klucz active i wartość odpowiadającą bieżącej stronie dla każdego widoku. Do takiego podejścia zainspirowało mnie to pytanie ze StackOverflow.

A oto efekt:

Ten pokaz slajdów wymaga włączonego JavaScript.

Daj Się Poznać 2017, Programowanie, Python, TaskList

Zmieniamy sposób wybierania daty

Wpisywanie daty z palca nie jest zbyt wygodne. Jest narażone na błędy ludzkie, istnieje wiele formatów zapisu daty i różnie można interpretować daty. Dlatego będzie dostępny kalendarz do wybrania.

Po dłuższym przeszukiwaniu przez Google’a znalazłem django-bootstrap3-datetimepicker, dzięki któremu osiągnę swój cel: zgodny z django i bootstrapem pobieracz daty w formularzu.

Najpierw instalacja:

pip install django-bootstrap3-datetimepicker

Potem dopisujemy do INSTALLED_APPS w settings.py

'bootstrap3_datetime'

Niestety, okazało się, że z nowym Django nie chce współpracować. Dlatego zmiana na forka.

Na razie wprowadziłem generowanie formularzy wykorzystujących Bootstrapa. Oto efekt:

screenshot-localhost 8000-2017-04-08-19-18-34

Usunąłem poprzednią wersję i zainstalowałem nową tym poleceniem:

pip install django-datetime-widget

Następnie zmiana wpisu w settings.py


INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'bootstrapform',
'datetimewidget',
'TaskList'
]

Potem nadpisałem widżety dla pól start i end:

widgets = {
    'start': DateTimeWidget(options=DateTimeOptions, usel10n=True, bootstrap_version=3),
    'end': DateTimeWidget(options=DateTimeOptions, usel10n=True, bootstrap_version=3)
}

Oto efekt:

screenshot-localhost 8000-2017-04-08-20-06-29

W następnym wpisie poruszymy inny temat związany z ulepszeniami aplikacji.

Daj Się Poznać 2017, TaskList

Pokierujmy projektem dalej…

Projekt się rozwija jak liście u drzew, więc pora zastanowić się, co dalej.

Jeśli chodzi o dalszy rozwój, rozważmy cztery rzeczy:

Pierwsza rzecz: można pomyśleć o wygodniejszym wybieraniu daty i godziny, zamiast wpisywania jej.

Druga rzecz: odstęp czasu po polsku.

Trzecia rzecz: dodatkowe statystyki związane z zadaniami, takie jak histogram czasu wykonywania zadań, średni czas wykonywania zadań itp.

Czwarta rzecz: przymiarki do wdrożenia na lokalny serwer WWW i wyłączenia trybu Debug. Potem rozważenie, czy udostępnić aplikację na Heroku lub jakimś innym hostingu z Django.

Co z tego wyjdzie? Zobaczymy!