Projekt z baz danych 2 – Karol Wilk, Jakub Gonet
Opis
Projekt zakłada stworzenie systemu do rejestracji na zajęcia na uczelni. Studenci zostają przypisani do odpowiedniej grupy rocznikowej oraz są w stanie przydzielić punkty na poszczególne terminy zajęć z przedmiotów.
Po zamknięciu zapisów moderatorzy roku przypisują studentów do grup uwzględniając ich preferencje. Po zakończeniu przydziału do grup studenci mogą zobaczyć wybrany plan.
Technologie
Do stworzenia aplikacji wykorzystano język Elixir i framework Phoenix. Silnikiem bazodanowym jest PostgreSQL, używamy biblioteki Ecto ułatwiającej operacje na bazie danych.
Instalacja
Docker
docker-compose up
Lokalna instalacja
mix deps.get
mix ecto.setup
(cd assets && npm install)
# Włączenie serwera
mix phx.server
Serwer działa pod adresem http://localhost:4000/.
Struktura projektu
Phoenix generuje parę folderów przy tworzeniu nowego projektu.
tree -d -L 3 -I 'node_modules|deps' .
.
├── assets # zasoby potrzebne do zbudowania strony - js, css, obrazki i fonty
│ ├── css
│ ├── js
│ └── static
| ├── fonts
│ └── images
├── config # konfiguracja bibliotek - serwera http, drivera bazy danych, etc
├── lib # kod aplikacji
│ ├── plan_picker # kod biznesowy, zawiera modele
│ │ ├── accounts
│ │ └── timestamp
│ └── plan_picker_web # część webowa, oparta o MVC
│ ├── channels
│ ├── controllers
│ ├── templates # w Phoenixie katalog views/ to głównie helpery, w templates znajduje się renderowany html
│ └── views
├── priv # zasoby, które nie są kodem źródłowym - migracje baz danych, translacje, pliki statyczne (kopiowane automatycznie z assets/)
│ ├── gettext
│ │ └── en
│ ├── repo
│ │ └── migrations # migracje, które definiują schemat bazy przy uruchomieniu mix ecto.setup
│ └── static
│ ├── css
│ ├── fonts
│ ├── images
│ └── js
└── test # testy
├── plan_picker
├── plan_picker_web
│ ├── controllers
│ └── views
└── support
└── fixtures
Więcej o budowie projektu pod tym linkiem.
Ważniejsze pliki:
priv/repo/migrations/20210417133131_add_initial_schema.exs
- schemat bazy danych opisany przy pomocy DSL podbiblioteki Ecto służącej do migracjipriv/repo/seeds.exs
- początkowe dane wczytane przy mix ecto.setuplib/plan_picker_web/router.ex
- definicje ścieżek dostępnych z przeglądarkilib/plan_picker_web/controllers/<nazwa>_controller
- kontroler modelu o nazwie<nazwa>
config/config.exs
- konfiguracja bibliotek, w plikachdev.exs
iprod.exs
znajdują się nadpisania dla środowiska deweloperskiego i produkcyjnego
Aktorzy
Student
- Ma dostęp do swoich danych (dane; rocznik; zapisy, do których jest dodany)
- Może wybrać przedmioty w ramach zapisów
- Może przydzielić ograniczoną liczbę punktów w ramach zapisu na dany termin
- Może zobaczyć przydzielony termin
- Może zobaczyć skład grup zajęciowych, których jest częścią
Moderator
- Te same uprawnienia co Student
- Może tworzyć, edytować i usuwać zapisy w obrębie swojego rocznika
- Może przenosić studentów pomiędzy grupami, gdy zapisy są zamknięte
- Może wyświetlać skład wszystkich grup zajęciowych w ramach zapisu
Administrator
- Te same uprawnienia co Moderator
- Ma dostęp do wszystkich zapisów
- Może przydzielać role
- Może dodawać, usuwać i edytować konta studentów
Schemat bazy
Znaczenie poszczególnych tabel
users
,teachers
- studenci i nauczycieleroles
- role użytkowników ze szczególnymi uprawnieniamiusers_tokens
- tokeny sesji użytkownikówenrollments
- zapisy, na które studenci mogą się rejestrowaćsubjects
- przedmioty w ramach zapisuclasses
- grupy dziekańskie w ramach przedmiotuterms
- posczególne terminy (jedna grupa może mieć czasami zajęcia w wielu terminach)enrollments_users
- w zapisie brać udział mogą tylko studenci do niego przypisani, jeden student może przypisany do wielu zapisówclasses_users
- do grupy dziekańskiej przypisywani są studenci, jeden student może przypisany do wielu grup dziekańskichpoints_assignments
- studenci mogą przyznawać grupom dziekańskim punkty priorytetu
Opis poszczególnych części projektu
Elixir
Elixir jest językiem funkcyjnym, stworzonym na podstawie języka Erlang oraz inspirowany syntaxem Rubiego. Przyświeca mu idea "let it crash" - w przypadku nieprawidłowych danych proces obsługujący dane żądanie powinien zakończyć swoje działanie. W Erlangu zazwyczaj zapytania obsługuje wiele procesów (procesy w VM erlanga są bardzo lekkie i nie są procesami systemowymi), dlatego obsługa w ramach jednego procesu skutecznie izoluje je od siebie - crash jednego procesu nie ma wpływu na inne procesy.
Typy danych
Głównymi typami wartości są stringi (przechowywane binarnie w UTF-8) " ą alamakota"
, liczby i atomy :nazwa
. Atomy służą jako unikalne symbole (jest górna liczba liczby symboli dostępnych w VM erlanga), często w kluczach map i struktur, ale też w opcjach przesyłanych do funkcji. Elixir nie ma typu boolean, realizują go atomy :true
i :false
.
Głównymi typami kolekcji w Elixirze są listy [1,2,3]
, krotki {1,2,3}
, mapy %{}
i struktury %NazwaStruktury{}
.
Pattern matching
Przypisanie wartość do zmiennej to strukturalne związanie wartości z nazwą. Nie widać tego przy zwykłych przypisaniach:
x = 5
ale widać, gdy przypisujemy liczbę czy krotkę:
{x, y} = {1, 2}
# x == 1
# y == 2
%{a: 1, b: {t, s}} = %{a: 1, b: { [1,3], 5}}
# t == [1,3]
# s == 5
W drugim przykładzie próbujemy związać mapę, która w Elixirze ma syntax %{klucz: wartość}
.
Najpierw upewniamy się, że klucz :a
ma wartość 1, potem destrukturyzujemy klucz :b
, z którego wyciągamy krotkę dwóch zmiennych.
Przykład z kodu
def create(conn, %{"user" => user_params}) do
%{"email" => email, "password" => password} = user_params
if user = Accounts.get_user_by_email_and_password(email, password) do
UserAuth.log_in_user(conn, user, user_params)
else
render(conn, "new.html", error_message: "Invalid email or password")
end
end
Funkcja create/2
w user_session_controller.ex
drugi argument dopasowuje do mapy z kluczem "user", a następnie dopasowaną wartość dopasowuje do mapy z emailem i hasłem. Warto zauważyć, że kluczami są tutaj stringi, ponieważ argument jest kontrolowany przez klientów. Użycie atomu byłoby błędem, ponieważ klient mógłby generować argumenty, które tworzyłyby nowe atomy i w którymś momencie przekroczylibyśmy maksymalną liczbę atomów oraz scrashowalibyśmy VM erlanga.
defp parse_weekday_lower("pn"), do: :monday
defp parse_weekday_lower("wt"), do: :tuesday
defp parse_weekday_lower("sr"), do: :wednesday
defp parse_weekday_lower("cz"), do: :thursday
defp parse_weekday_lower("pt"), do: :friday
Funkcja parse_weekday_lower/1
w pliku csv.ex
mapuje stringi, reprezentujące dni tygodnia na atomy. Pattern matching w funkcjach działa z góry do dołu, więc bardzo często się korzysta z tego mechanizmu do zastępowania ifów:
defp parse_group("", :lecture), do: nil
defp parse_group(group, _) when group != "", do: String.to_integer(group)
parse_group/2
najpierw próbuje dopasować wzorzec do pustego stringa i atomu reprezentującego wykład, jezeli mu to się uda to zwraca specjalny atom nil
, jeżeli nie, to próbuje dopasować każdy niepusty string i sparsować numer grupy. Wysokopoziomowo ta funkcja mapuje grupy wykładowe (które nie mają numeru) na nil, a w reszcie zamienia numer grupy ze stringu na liczbę. Warto zauważyć, że jeśli podalibyśmy jako argument np. parse_group("", :laboratory)
to żadna z klauzul funkcji nie jest w stanie się dopasować i dostaniemy błąd dopasowania.
Pipe operator
Kolejnym ważnym elementem Elixira jest operator |>
. Pozwala na przekazanie wartości jako pierwszego argumentu funkcji, więc zapis f(g(x))
zamienia się na x |> g() |> f()
.
Przykład z kodu
def show(conn, %{"id" => enrollment_id}) do
terms =
enrollment_id
|> Enrollment.get_enrollment!()
|> Enrollment.get_terms_for_enrollment()
render(conn, "show.html", terms: terms)
end
Funkcja show/2
z enrollment_controller.ex
po wyciągnięciu enrollment_id
przekazuje ją do funkcji get_enrollment/1
z modułu Enrollment
, który robi zapytanie do bazy danych, a następnie drugi wywołuje funkcję get_terms_for_enrollment/1
, która robi zapytanie o terminy związane z zapisem i strukturyzuje dane w listę terminów z dodatkowymi informacjami nt nauczycieli czy grup.
Ecto
Ecto jest biblioteką dostępu i generowania zapytań do danych. Nie jest ORMem - jest całkowicie agnostyczna co do tego gdzie przechowujemy dane czy w jaki sposób do nich się odwołujemy (oraz nie jest obiektowa :>).
Bardzo często korzysta się z podbibliotek Ecto, które stanowią pomost pomiędzy DBMS, a Ecto (np Postgrex).
Ecto w naszej aplikacji składa się z dwóch elementów:
- modelowania fizycznej struktury bazy danych
- dostępu do danych i zaprojektowania modelu logicznego
Struktura bazy danych i migracje
Wszystkie migracje znajdują się w folderze priv/repo/migrations
, szczególnie istotne są dwie pierwsze: setup_db_extensions
oraz add_initial_schema
. W pierwszym inicjalizujemy wtyczki do postgresa, citext oraz btree_gist, odpowiednio do typu citext
, który dostarcza funkcjonalności varcharów, ale bez uwzględniania wielkości liter (używane do przechowywania maili) i do stworzenia indeksów na przedziały czasowe.
add_initial_schema
z kolei tworzy podstawową strukturę danych. Skupmy się na jednej migracji:
defp add_terms do
create_query = "CREATE TYPE week_type AS ENUM ('A', 'B')"
drop_query = "DROP TYPE week_type"
execute(create_query, drop_query)
create table(:terms) do
add :interval, :tstzrange, null: false
add :location, :string
add :week_type, :week_type
add :class_id, references(:classes, on_delete: :delete_all)
timestamps()
end
create constraint("terms", :in_two_week_range,
check: "interval <@ '[1996-01-01 00:00, 1996-01-14 00:00]'"
)
create constraint("terms", :no_overlap_in_group,
exclude: "gist (class_id WITH =, interval WITH &&)"
)
create index(:terms, [:class_id])
end
Po kolei:
-
Dodanie enuma
week_type
Definiujemy dwie komendy z PG, które tworzą i niszczą typ użytkownika, który jest zdefiniowany jako enum. W ten sposób ograniczamy wartość pola week_type do
"A", "B", null
co przekłada się na rodzaj tygodnia bądź oba tygodnie (null).Potrzebujemy zdefinować tworzenie i niszczenie, by móc używać komendy
mix ecto.rollback
, która nie jest obowiązkowa, ale pozwala na cofanie się z aplikacją migracji.create_query = "CREATE TYPE week_type AS ENUM ('A', 'B')" drop_query = "DROP TYPE week_type" execute(create_query, drop_query)
-
Stworzenie tabeli o nazwie
"terms"
, atom mapowany jest na stringcreate table(:terms) do
-
Dodanie interwału, który jest typem interwałowym ze strefą czasową..
W ten sposób modelujemy przedziały godzin, które są dokładniej opisane w kolejnej sekcji.
add :interval, :tstzrange, null: false
-
Dodanie asocjacji przez klucz obcy z klasą i użycie typu kaskady
DELETE ALL
add :class_id, references(:classes, on_delete: :delete_all)
-
Dodanie pól
created_at
,updated_at
przez makrotimestamps()
Przydatne w sortowaniu po dacie stworzenia i podobnych zapytaniach.
timestamps()
-
Ograniczenia na interwały
Musimy zamodelować terminy, które zamykają się w dwóch tygodniach, dlatego użycie dat nie jest optymalnym rozwiązaniem. Interwały z PG pozwalają na zamknięcie dat w określonym przedziale (tutaj przyjęliśmy początek jako 1996-01-01, a koniec 1996-01-14 - pierwszy stycznia 1996 to był poniedziałek, więc łatwo mapuje się na dni tygodni) oraz na zabronienie przecinania się z innym przedziałem w obrębie tej samej grupy. Jest to dość istotne ograniczenie, ponieważ zapobiega sytuacji, w której jedna grupa ma zajęcia jednocześnie albo się pokrywają, co jest niemożliwe do zrealizowania w rzeczywistości. Tutaj używamy indeksu drzewiastego gist, który był omówiony wcześniej.
create constraint("terms", :in_two_week_range, check: "interval <@ '[1996-01-01 00:00, 1996-01-14 00:00]'" ) create constraint("terms", :no_overlap_in_group, exclude: "gist (class_id WITH =, interval WITH &&)" )
Domyślnie każda tabela ma klucz
id
, który ma typ int. My zmieniliśmy to ustawienie używając UUID, ponieważ jest nieco bezpieczniejsze, zapobiegając atakom enumeracyjnym (iteracja po endpoincie REST:/user/1
,/user/2
,/user/n
) oraz umożliwia używanie shardingu, dzieląc bazę na niezależne podczęści (UUID jest globalnie unikalnie, więc nie musimy synchronizować sekwencji).
Model logiczny
Ecto składa się z trzech głównych części:
- repozytorium
- modeli
- changesetów i dostępu do danych
Repozytorium
Repozytorium jest miejscem, które definiujemy jako źródło danych. U nas jest to Postgres, definicja znajduje się w pliku repo.ex
Modele
Modele reprezentują struktury, jakich używamy w aplikacji. Przykładowo w pliku term.ex
widzimy taką definicję:
schema "terms" do
field :raw_interval, :map, virtual: true
field :interval, Timestamp.Range
field :location, :string
field :week_type, :string
belongs_to :class, Class
timestamps()
end
Widać wymienione wyżej pola, asocjację z klasą oraz dodatkowo pole wirtualne :raw_interval
. Jest ono wykorzystywane do stworzenia pola interval i jest mapą %{start: start_t, end: end_t, weekday: weekday}
reprezentującą czas rozpoczęcia i skończenia terminu oraz dzień tygodnia, w którym dany termin się odbywa. Plik range.ex
zajmuje się mapowaniem tych informacji na typ w bazie danych w funkcji from_time
, która wykorzystuje new
i dump
do serializacji i deserializacji interwałów.
Changesety
Changesety to struktury przechowujące zmiany, które chcemy zaaplikować na danym wierszu. Przykładowo w term.ex
widzimy funkcję changeset
:
def changeset(term, attrs) do
term
|> cast(attrs, [
:location,
:week_type,
:raw_interval
])
|> set_interval()
|> validate_required([:interval, :location])
end
Funkcja ta najpierw używa funkcji cast
do odfiltrowania interesujących nas pól - do changeset
przekazujemy dowolną mapę, więc chcemy uniemożliwić przypadkową zmianę pola, którego nie chcemy zmienić w danym changesecie.
Następnie używamy metody set_interval
, która próbuje stworzyć nową strukturę interval
. To, że udało się jej ją stworzyć sprawdzane jest w kolejnym kroku validate_required
. Jeżeli changeset jest prawidłowy to ma pole valid?
ustawione na true
, jeżeli nie jest to ma valid? == false
oraz listę błędów w errors
.
W pliku user.ex
można zauważyć podział odpowiedzialności changesetów, istnieje tam password_changeset
, email_changeset
czy registration_changeset
.
Dostęp do danych
Przykładem dostępu do danych i używania repozytorium jest plik role.ex
.
def has_role?(user, role_name) do
query =
from u in Accounts.User,
join: r in assoc(u, :role),
where: r.name == ^role_name and u.id == ^user.id
Repo.exists?(query)
end
W has_role?
definiujemy funkcję sprawdzającą czy dana struktura reprezentująca usera ma daną rolę.
Najpierw tworzymy zapytanie w DSL Ecto, które wygląda bardzo analogicznie do zapytań SQL, a następnie używamy funkcji Repo.exists?
, która zwraca prawdę jeżeli z wygenerowanego zapytania zostanie zwrócony co najmniej jeden wiersz.
Jednym z elementów zapytania jest znak ^
: w zwykłym Elixirze gdy chcemy sprawdzić strukturalnie wartość zmiennej używamy go do zapobiegnięcia dowiązania zmiennej do nazwy:
x = 5
# 5
x = 4 # zmienna x jest dopasowana do 4 przez zmianę dowiązania
# 4
^x = 3 # zmienna nie może się dopasować, ponieważ zabroniliśmy jej zmienić dowiązanie
# ** (MatchError) no match of right hand side value: 3
W Ecto znak jest przeładowany i służy do poprawnego escape'owania wartości w zapytaniu by uniknąć ataków SQL injection. Brak użycia ^
kończy się błędem.
Drugi przykład:
def assign_role(user, role_name) do
if not has_role?(user, role_name) do
%Role{name: role_name}
|> Repo.preload(:user)
|> Ecto.Changeset.change()
|> Ecto.Changeset.put_assoc(:user, user)
|> Repo.insert!()
end
end
assign_role
przypisuje daną rolę do użytkownika.
Najpierw sprawdzamy czy użytkownik nie posiada już danej roli, jeżeli nie, to tworzymy strukturę, która ją reprezentuje, tworzymy z niej changeset, tworzymy asocjację z podanym userem i próbujemy ją dodać do bazy.
Elixir ma konwencję, w której funkcje zakończone wykrzyknikiem rzucają wyjątek przy niepoprawnym użyciu (tutaj gdy INSERT sie nie powiedzie), a funkcje bez wykrzyknika zwracają krotkę {:ok, value}
bądź {:error, reason}
pozwalając użyć dopasowania wzorca i wykonania innych funkcji. Widać to w verify_change_email_token_query
w pliku user_token.ex
Phoenix
Phoenix jest frameworkiem do budowy aplikacji webowych realizującym model MVC, został stworzony o doświadczenia z frameworkiem Rails.
Plugs
Fundamentalnym elementem Phoenixa są plugi. Są to moduły lub funkcje, które realizują wspólny interfejs, dzięki czemu są łatwo komponowalne.
Używa się ich głównie w routerze, tworząc pewną analogię do pipe'ów (|>
). By funkcja działała jako plug, musi jako pierwszy argument przyjmować argument połączenia conn
oraz opcje.
W pliku user_auth.ex
znajduje się parę plugów, które zajmują się autoryzacją ról:
def require_role(conn, role) do
if Role.has_role?(current_user(conn), role) do
conn
else
conn
|> put_flash(:error, "You do not have required permissions to view this page.")
|> redirect(to: Routes.user_session_path(conn, :new))
|> halt()
end
end
Sprawdzamy czy aktualny użytkownik ma wymaganą rolę. Jeżeli tak, zwracamy conn
bez zmian, jeżeli nie to przekierowujemy do strony umożliwiającej zalogowanie i zatrzymujemy cały pipeline. Aktualny użytkownik jest wybierany z conn.assigns
, pozwalając na dzielonie stanu pomiędzy plugami.
Router
router.ex
Router używa paru podstawowych pipeline'ów składających się z listy plugów:
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_user
plug :put_root_layout, {PlanPickerWeb.LayoutView, :root}
end
pipeline :require_authenticated_user_having_data do
plug :require_authenticated_user
plug :put_roles_if_authenticated
end
pipeline :require_moderator_role do
plug :require_authenticated_user_having_data
plug :require_role, :moderator
end
pipeline :require_admin_role do
plug :require_authenticated_user_having_data
plug :require_role, :admin
end
browser
zapewnia, że akceptujemy wyłącznie połączenia z nagłówkiem Accept: text/html
(plug accepts, ["html"]
), dostajemy dostęp do sesji, dodajemy obsługę liveview czy zabezpieczamy się przed różnymi typami ataków.
require_moderator_role
i require_admin_role
sprawdzają czy zalogowany użytkownik posiada odpowiednią rolę, jest to wykorzystywane w routingu.
Niżej znajdują się opisy ścieżek, które mapowane są do kontrolerów i kontrolerów liveView
scope "/manage/", PlanPickerWeb do
pipe_through [:browser, :require_moderator_role]
get "/enrollments/", EnrollmentManagementController, :index
get "/enrollments/:id/show", EnrollmentManagementController, :show
live "/enrollments/:id/edit", EnrollmentManagementLive, :edit
live "/enrollments/:id/classes", ClassManagementLive, :classes
end
scope
to prefix ścieżki, a nazwa modułu jest prefiksem modułów wymienionych w ścieżkach. np. get "/enrollments/", EnrollmentManagementController, :index
mapuje się na ścieżkę /manage/enrollments
udostępnianej pod metodą GET i uruchamia funkcję index/2
w module PlanPickerWeb.EnrollmentManagementController
. W ścieżkach widać mapowanie parametrów HTML na argumenty przekazywane do zmapowanej funkcji get "/enrollments/:id/show"
udostępni id w drugim argumencie jako mapie %{"id" => numeryczne_id}
.
MVC
Framework Phoenix realizuje model MVC, często spotykany w rozwiązaniach webowych.
Modele realizowane są jako struktury w definiowane części biznesowej kodu źródłowego lib/plan_picker
. Wraz z ich strukturą definiowane są też funkcje pozwalające na przetwarzanie danych związanych z tym modelem.
Przykładem jest plik subject.ex
, opisujący model przedmiotu.
schema "subjects" do
field :name, :string
has_many :classes, PlanPicker.Class
belongs_to :enrollment, PlanPicker.Enrollment
timestamps()
end
Opisana jest w nim struktura danych, ale także funkcje z nią związane:
def create_subject!(subject_params, enrollment) do
%PlanPicker.Subject{}
|> changeset(subject_params)
|> put_assoc(:classes, [])
|> put_assoc(:enrollment, enrollment)
|> Repo.insert!()
end
def get_subject!(subject_id, opts \\ [preload: [classes: [:teacher, :users, :terms]]]) do
PlanPicker.Subject
|> Repo.get!(subject_id)
|> Repo.preload(opts[:preload])
end
Warto zauważyć, że w funkcji get_subject!
oprócz funkcji Repo.get!
służącej do pobierania danych z bazy danych, użyta jest też funkcja Repo.preload
, pobierająca powiązania (w tym przypadku grupy danego przedmiotu, a z każdej grupy nauczyciela, studentów i terminy).
W celu zachowania separacje między warstwami systemu, jedynie funkcje udostępnione przez model są wykorzystywane przez kontrolery do zarządzania danymi, np. w pliku lib/plan_picker_web/controllers/enrollment_controller.ex
:
def show(conn, %{"id" => enrollment_id}) do
terms =
enrollment_id
|> Enrollment.get_enrollment!()
|> Enrollment.get_terms_for_enrollment()
render(conn, "show.html", terms: terms)
end
Funkcja show
zdefiniowana w kontrolerze EnrollmentController
korzysta z funkcji get_enrollment!
i get_terms_for_enrollment
w modelu Enrollment
. Następnie, korzystając z funkcji render
, przekazując w niej dane z modelu do widoku.
Kontroler
Kontroler w podstawowej aplikacji Phoenixa jest realizowany jako zestaw funkcji przetwarzających dane. W routerze zdefiniowane jest, kiedy dane funkcje kontrolera są wykonywane.
Kontroler jest pośrednikiem pomiędzy modelem logicznym aplikacji a jej widokiem od strony użytkownika. Wszystkie operacje na modelu są wykonywane przed renderowaniem strony HTML (z wyjątkiem mechanizmu LiveView) - jest to zasada we frameworku Phoenix - widok jest generowany deterministycznie z danych w modelu.
Kontroler może przetworzone dane przekazać dalej widokowi za pomocą funkcji render
, tak jak w enrollment_controller.ex
:
def index(conn, _params) do
enrollments = conn |> UserAuth.current_user() |> Enrollment.get_enrollments_for_user()
render(conn, "index.html", enrollments: enrollments)
end
Jak widać na powyższym przykładzie, zapisy dla użytkownika są pobierane z bazy, a po pobraniu są przypisywane do symbolu enrollments
. Dzięki temu będą one mogły być użyte przez widok.
Poza renderowaniem widoku, kontroler może manipulować połączeniem w inne sposoby. Jednym z nich jest przekierowanie za pomocą funkcji redirect
:
def delete(conn, %{"id" => enrollment_id}) do
Enrollment.delete_enrollment!(enrollment_id)
conn
|> put_flash(:info, "Enrollment deleted.")
|> redirect(to: Routes.enrollment_management_path(conn, :index))
end
W tym przypadku funkcja delete
w enrollment_management_controller.ex
przekieruje użytkownika do ścieżki wywołującej funkcję index
z tego samego kontrolera.
View i template
Widoki i szablony są bardzo powiązanymi bytami: każdy szablon jest skompilowany do modułu widoku i jest zwracany jako wartość funkcji render
, dlatego każdy kontroler musi być skojarzony z widokiem i każdy szablon, który może być renderowany musi być powiązany z widokiem. Tradycyjnie w module widoku definiuje się funkcje pomocnicze przydatne w szablonach: enrollment_view.ex
ma funkcję get_width_perc
liczącą procent przydzielonych przez użytkownika punktów w porównaniu do maksymalnej liczby w szablonie points_assignments.html.leex
:
def get_width_perc(0), do: 0
def get_width_perc(points) when points > 0,
do: trunc(100 * points / PlanPicker.PointAssignmentLive.max_points())
Szablony umożliwiają dodawanie wyrażeń Elixira przez specjalny język tagów: <%= %>
renderuje do HTML wartość zwróconą w środku tagu, <% %>
tego nie robi. Przykładem jest plik _terms.html.eex
:
<%= for term <- @terms do %>
<div class="term-overlay mb-3">
<%= live_render(@conn, PlanPicker.PointAssignmentLive, session: %{"term_id" => term.id}) %>
<div class="box content is-size-6">
<p class="mb-0">
<strong><%= term.name %></strong> <%= term.type %><%= prefix_if_not_empty(term.group_number, ", ") %><%= term |> display_week_type() |> prefix_if_not_empty(" - ") %>
</p>
<p class="mb-0"><%= Timestamp.Range.to_human_readable_iodata(term.interval) %></p>
<p><%= term.location %></p>
<span><%= term.teacher.name %> <%= term.teacher.surname %></span>
</div>
</div>
<% end %>
Jak widać, elementy HTML są zamknięte w wyrażeniu for
, co sprawia, że są renderowane dla każdego terminu w @terms
. @terms
to dane przypisane wcześniej do symbolu terms
za pomocą funkcji render
. Znaczniki <%= %>
są używane do wyświetlania danych dotyczących terminu. Jest też użyta funkcja helper prefix_if_not_empty
zdefiniowana w pliku widoku enrollment_view.ex
.
Phoenix LiveView
Phoenix LiveView jest rozwiązaniem pozwalającym na tworzenie widoków dynamicznych w Phoenix. Jest on rozwiązaniem problemu prezentowanego przez domyślny system kontrolerów i widoków Phoenix, pozwalających jedynie na tworzenie stron statycznych, gdyż (w przeciwieństwie do Phoenix View) LiveView pozwala na dynamicze zmiany stanu.
Zasada deterministycznej generacji HTML nadal jest zachowana - odpowiednie elementy strony są regenerowane przy zmianie danych.
W przeciwieństwie do domyślnego widoku, LiveView jest odpowiedzialny za interakcję z modelem. Aby otrzymać tę funkcjonalność, należy zdefiniować funkcje mount
oraz render
.
mount
Funkcja Funkcja mount
jest wykonywana przed renderowaniem widoku. LiveView nie ma dostępu do połączenia conn
- nie pozwala ono na dynamiczną zmianę stanu. Zamiast niego używane jest gniazdo socket
. Funkcja mount inicjalizuje dane w gnieździe. Musi zwrócić krotkę w postaci {:ok, socket}
, gdzie socket
to gotowe gniazdo.
def mount(%{"id" => enrollment_id}, %{"user_token" => token} = _session, socket) do
enrollment = Enrollment.get_enrollment!(enrollment_id)
user = Accounts.get_user_by_session_token(token)
roles = Role.get_roles_for(user)
socket =
if user in enrollment.users || :admin in roles do
subject = get_first_subject(enrollment)
socket
|> assign(:enrollment, enrollment)
|> assign(:selected_subject, subject)
|> assign(:selected_class, nil)
|> assign(:selected_users, [])
|> assign(:points_assignments, %{})
else
socket
|> put_flash(:error, "You do not have required permissions to view this enrollment.")
|> redirect(to: Routes.enrollment_management_path(socket, :index))
end
{:ok, socket}
end
W powyższym przykładzie z pliku class_management_live.ex
funkcja pobiera wartość enrollment_id
z parametrów ścieżki, oraz token sesji użytkownika token
. Potem funkcja przypisuje do poszczególnych symboli początkowe wartości z modelu (lub przekierowuje do innej strony, gdy użytkownik nie ma odpowiednich uprawnień).
To zachowanie jest podobne do zachowania kontrolera w klasycznym Phoenixie.
render
Funkcja Funkcja render
ma taką samą funkcjonalność jak w klasycznym widoku. Różnica polega na tym, że kiedy dane w gnieździe (oznaczone zmienną assigns
) się zmienią, części szablonu które korzystały z tych danych są ponownie generowane.
def render(assigns) do
render(EnrollmentView, "points_assignments.html", assigns)
end
LiveView może także korzystać z już istniejącego widoku, jak w powyższym przykładzie z pliku ponts_assignments_live.ex
. Widok będzie wtedy dynamicznie regenerowany pod warunkiem, że plik definiujący renderowany szablon (w tym przypadku points_assignments.html.leex
) ma rozszerzenie *.html.leex
.
Co ciekawe, funkcja render
nie musi być implementowana przez programistę - jeżeli w katalogu lib/plan_picker_web/live
znajduje się plik *.html.leex
o takiej samej nazwie co moduł LiveView, będzie on renderowany automatycznie. Taka sytuacja zachodzi w plikach class_management_live.ex
oraz class_management_live.html.leex
.
handle_event
Funkcja Stan w LiveView może zmieniać się pod wpływem wielu wydarzeń. Najczęściej używaną są wydarzenia generowane przez akcje użytkownika. Aby skorzystać z tego mechanizmu, należy zdefiniować funkcję handle_event
.
def handle_event("toggle_user", %{"id" => user_id}, socket) do
user = Accounts.get_user!(user_id)
selected_users = socket.assigns[:selected_users]
if user in selected_users do
{:noreply, assign(socket, :selected_users, List.delete(selected_users, user))}
else
{:noreply, assign(socket, :selected_users, [user | selected_users])}
end
end
def handle_event("select_subject", %{"id" => subject_id}, socket) do
case socket.assigns[:selected_subject].id do
^subject_id ->
{:noreply, socket}
_ ->
new_subject = Subject.get_subject!(subject_id)
{:noreply, assign(socket, :selected_subject, new_subject)}
end
end
def handle_event("select_class", %{"id" => class_id}, socket) do
selected_class = socket.assigns[:selected_class]
# ...
W powyższym przykładzie w class_management_live.ex
w odpowiedzi na zdarzenia, LiveView uaktualnia stan za pomocą funkcji assign
.
Aby generować wydarzenie, Phoenix LiveView dostarcza specjalne atrybuty do elementów HTML, w szczególności phx-click
oraz phx-value-<nazwa_wartości>
:
<li phx-click="select_subject" phx-value-id="<%= subject.id %>">
<a> <%= subject.name %> </a>
</li>
Po kliknięciu w link, wygenerowane będzie zdarzenie "select_subject", a jako wartość zostanie wysłana mapa %{"id" => <id_przedmiotu>}
. Dane te są wykorzystane w funkcji handle_event
:
def handle_event("select_subject", %{"id" => subject_id}, socket) do
case socket.assigns[:selected_subject].id do
^subject_id ->
{:noreply, socket}
_ ->
new_subject = Subject.get_subject!(subject_id)
{:noreply, assign(socket, :selected_subject, new_subject)}
end
end
W tym przypadku, jeżeli użytkownik kliknął na przedmiot inny niż ten związany z symbolem :selected_subject
, zostanie on odpowiednio pobrany z bazy oraz aktualizowany w gnieździe.