Aplikacja „Ale kino”

Filmy są wyświetlane w kinach. Wygodnie było by wejść na stronę z listą filmów wyświetlanych danego dnia. Umożliwienie recenzowania filmów i przy okazji przydzielanie im „gwiazdek” zwiększyły by funkcjonalność serwisu.

Oczywiście, dodatkowo przy każdym filmie powinnien się pojawić czas jego projekcji oraz ocena w gwiazdkach.

Pierwszy kod

Z powyższego opisu wynika, że potrzebujemy trzy modele: Film, Kino i Recenzja.

Zależność między modelami Film i Kino jest wiele-do-wielu: film może być wyświetlany w wielu kinach i kino może wyświetlać wiele filmów.

[Film]<->[Kino]

Zwyczajowo, zależność wiele-do-wielu zastępujemy dwiema zależnościami jeden-do-wielu oraz dodatkową tabelą. Często jest tak, że „pośrednicząca” tabela „awansuje” do modelu o ile jest potrzeba zapamiętania przy okazji dodatkowych danych. Tak jest i w tym wypadku, ponieważ oczywiście należy gdzieś zapisać od/do kiedy dany film będzie wyświetlany w konkretnym kinie. Potrzebujemy jeszcze jakiejś nazwy na ten model. Wydaje się, że nazwa Seans jest odpowiednia.

Teraz mamy następujące zależności jeden-do-wielu: Film jest wyświetlany na wielu Seansach oraz w Kinie mamy wiele Seansów. A po dodaniu atrybutów wygląda to tak:

[Film]<-[Seans]->[Kino]

Po tej analizie zabieramy się za wygenerowanie szkieletu aplikacji oraz layoutu:

rails ale-kino
script/generate nifty_layout

oraz trzech rusztowań. Ale zanim je wygenerujemy, nauczymy railsy liczby mnogiej rzeczowników: film, kino, seans i przy okazji recenzja. W tym celu, w pliku config/initializers/inflections.rb wpiszemy:

ActiveSupport::Inflector.inflections do |inflect|
  inflect.irregular 'kino', 'kina'
  inflect.irregular 'film', 'filmy'
  inflect.irregular 'seans', 'seanse'
  inflect.irregular 'recenzja', 'recenzje'
end

Stawiamy rusztowania

Zaczniemy od kin, nastepnie przejdziemy do filmów, a na koniec postawimy rusztowania dla seansów.

Kino

Listę kin się nie zmienia. Nazwy kin wpiszemy w pliku db/seed.rb:

# Gdynia
Kino.create(:name => 'Multikino (Silver Screen)')
Kino.create(:name => 'Klub Filmowy')
# Sopot
Kino.create(:name => 'Multikino Sopot')
Kino.create(:name => 'Kino Polonia')

Nie będziemy potrzebować widoków. Wygenerujemy tylko model:

script/generate model kino name:string

Usuwamy niepotrzebny widok index.html.erb, migrujemy i wypełniamy tabelkę kina danymi:

rake db:migrate
rake db:seed

Film

Dla filmów będziemy potrzebować kompletnego rusztowania ze wszystkimi widokami:

script/generate nifty_scaffold film name:string minutes:integer

Strona główna aplikacji podpinamy widok views/filmy/index.html.erb dopisując w config/routes.rb:

map.root :controller => "filmy"

kasując przy okazji domyślny routing oraz usuwając plik public/index.html:

rm public/index.html

Migrujemy:

rake db:migrate

i, dopiero teraz, uruchomiamy aplikację sprawdzając w ten sposób czy o czymś nie zapomnieliśmy i czy wszystko zostało zrobione tak jak trzeba:

script/server thin

Jeśli wszystko działa, to klikamy w link New film i widzimy, że mamy jeszcze sporo rzeczy do zrobienia. Ale to potem. Teraz zabierzemy się za recenzje

Dopisujemy kilka filmów do pliku db/seed.rb:

Film.create :name => "500 dni miłości", :minutes => 90
Film.create :name => "Granice miłości", :minutes => 60
Film.create :name => "Papierowy żołnierz", :minutes => 90
Film.create :name => "Paranormal Activity", :minutes => 120

Recenzja

Zależność między modelami: Film i Recenzja jest jeden-do-wielu. Film może mieć wiele recenzji. Recenzja dotyczy jednego filmu.

[Film]<-[Recenzja]

Z atrybutami może to wyglądać tak:

[Film]<-[Recenzja]

Co to praktycznie oznacza? Jak wyglądają tabele filmy i recenzje? Jak „zakodowane” jest powiązanie między tabelkami/modelami? Skąd się biorą pola id? Pole film_id?

Zanim się temu przyjrzymy generujemy rusztowanie dla modelu Recenzja. Będziemy potrzebować widoku new (czy aby na pewno?) i chyba index z listą ostatnio wpisanych recenzji. Przyda się jeśli będziemy recenzje moderować.

script/generate nifty_scaffold recenzja \
  author_name:string content:text stars:integer \
  film_id:integer index new
rake db:migrate

W modelu Recenzja dopisujemy:

class Recenzja < ActiveRecord::Base
  attr_accessible :author_name, :content, :film_id  # NEW! od Rails 2.3.5
  belongs_to :film
end

a w modelu Film:

class Film < ActiveRecord::Base
  attr_accessible :name, :minutes  # NEW!
  has_many :recenzje
end

Teraz oglądamy tabelki i wyciągamy wnioski. Uruchamiamy konsolę rails:

script/console

i wykonujemy kilka poleceń:

Film.create :name => "Paranormal Activity", :minutes => 120
Film.create :name => "Odlot (projekcje 3D)", :minutes => 60
film = Film.find(1)
recenzja = film.recenzje.build(:author_name => "Miki", :content => "super!")
recenzja.save
film.recenzje.create(:author_name => "Miki", :stars => 1)
film.recenzje
film.recenzje(1).destroy(1)

Wyszukiwanie na kilka sposobów:

film.recenzje.all(:conditions => {:author_name => "Miki" })
film.recenzje.find_by_author_name("Miki")

i jeszcze kilka sposobów:

film.recenzje.all :conditions => ["author_name=?", "Ja"]
film.recenzje.all :conditions => ["author_name LIKE ?", "Ja"]
film.recenzje.all :conditions => ["author_name LIKE ?", "%ik%"]

Przykłady:

Podstawowa dokumentacja jest w Guides albo a dokumentacji do API.

Do pliku db/seed.rb dopiszemy po jednej recenzji dla pierwszych dwóch filmów:

f1 = Film.find(1)
f2 = Film.find(2)
f1.recenzje.create :author_name => "Fiki", :contents => "strata czasu", :stars => 1
f2.recenzje.create :author_name => "Miki", :contents => "genialne!", :stars => 5

Seans (has_many :through)

Przypomnienie jak jest umiejscowiony model Seans:

[Film]<-[Seans]->[Kino]

Dopisujemy do modelu Kino:

has_many :filmy, :through => :seanse
has_many :seanse, :dependent => :destroy  # a to po co?

Jeśli usuniemy kino, to powinniśmy jednocześnie usunąć wszystkie seanse tego filmu właśnie co usuniętym kinie.

Do modelu Film:

has_many :kina, :through => :seanse
has_many :seanse, :dependent => :destroy

Jeśli usuniemy film, to powinniśmy jednocześnie usunąć wszystkie seanse na których jest on wyświetlany.

i na koniec, dopisujemy do modelu Seans:

class Seans < ActiveRecord::Base
  belongs_to :kino
  belongs_to :film
end

Pozostało jeszcze wygenerować rusztowanie:

script/generate model seans kino_id:integer film_id:integer \
  starts_on:date ends_on:date

Problemy z relacjami wiele-do-wielu

Jeśli zapomnimy o modelu Seans, to trudno będzie znaleźć odpowiedzi na poniższe pytania:

Jak to zrobić? Przetrenujemy to konsoli:

kino = Kino.first
kino.filmy << Film.first # problematyczne, ponieważ
kino.seanse              # nie są ustawione daty: starts_on end_on
kino.seanse.last.destroy # powracamy do sytuacji wyjściowej
kino.seanse              # z cache
kino.seanse(true)        # przeładuj bazę i pokaż

Tak jest ok:

kino = Kino.first
kino.seanse.create(:film => Film.first,
  :starts_on => 8.weeks.ago,
  :ends_on => 4.weeks.ago) # bardzo sprytne! dlaczego?
kino.seanse.create(:film => Film.first,
  :starts_on => 2.weeks.ago,
  :ends_on => 6.weeks.from_now)
kino.filmy        # z cache
kino.filmy(true)  # przeładuj bazę i pokaż

Aby usunąć Film.first z Kina.first, można postąpić na przykład tak:

Kino.first.seanse.all
Seans.destroy(Kino.first.seanse.find_all_by_film_id(Film.first))
Seans.all

Dopisujemy kilka seansów do db/seed.rb:

kino = Kino.first
film = Film.first
kino.seanse.create(:film => film, :starts_on => 8.weeks.ago, :ends_on => 4.weeks.ago)
kino.seanse.create(:film => film, :starts_on => 2.weeks.ago, :ends_on => 6.weeks.from_now)
film = Film.last
kino.seanse.create(:film => film, :starts_on => 6.weeks.from_now, :ends_on => 8.weeks.from_now)
kino = Kino.last
kino.seanse.create(:film => film, :starts_on => 4.weeks.ago, :ends_on => 4.weeks.from_now)
kino.seanse.create(:film => film, :starts_on => 6.weeks.ago, :ends_on => 2.weeks.ago)

Piszemy widoki

Zaczniemy od? Nie za bardzo wiadomo od czego? Może „od początku” czyli od strony głównej, czyli strony /filmy/index.

/filmy/index

Widok został wygenerowany automatycznie i strona ta wygląda ona tak:

/filmy/index (scaffold)

Chcemy ją zmienić na taką:

/filmy/index (scaffold)

Uwaga: później dodamy autoryzację i udostępnimy Edycję i Usuwanie tylko adminowi.

<% title "Filmy" %>
<%= render :partial => @filmy %>
<p><%= link_to "Nowy film", new_film_path %></p>

Czyli prawie cały widok znajdzie się w widoku częściowym. Dodany kilka elementów div aby łatwiej było stylizować stronę.

<div class="film">
  <div class="name"><%= link_to h(film.name), film_path(film) %></div>
  <div class="minutes">Czas: <%= film.minutes %> min.</div>
  <% unless film.recenzje.empty? %>
  <div class="recenzje">
    <%= display_stars(film.average_stars) %>
    (<%= link_to "zobacz recenzje", film_path(:id => film, :anchor => 'recenzje') %>)
  </div>
  <% end %>
    <div class="actions">
      <%= link_to "Edycja", edit_film_path(film) %> |
      <%= link_to "Usuń", film, :confirm => "Jesteś pewien?", :method => :delete %>
    </div>
</div>

Za liczbę wyświetlanych gwiazdek odpowiedzialna jest metoda average_stars modelu Film:

class Film < ActiveRecord::Base
  def average_stars
    recenzje.average(:stars)
  end

Gwiazdki wyświetlamy korzystając z funkcji pomocniczej display_stars. Kod tej metody jest „wielowarstwowy”:

module ApplicationHelper
  def display_stars(stars)
    content_tag :div, star_images(stars || 0), :class => 'stars'
  end
  def star_images(stars)
    (0...5).map do |n|
      star_image_tag(((stars-n)*2).round)
    end.join
  end
  def star_image_tag(value)
    image_tag "/images/#{star_image_name(value)}.gif", :size => '15x15'
  end
  def star_image_name(value)
    if value <= 0
      'small_empty_star'
    elsif value == 1
      'small_half_star'
    else
      'small_full_star'
    end
  end
end

Na koniec stylizujemy stronę dopisując do application.css:

.film {
  margin: 1em 0;
}
.film .name {
  font-size: 140%;
  margin-bottom: 0.25em;
}
.film .minutes {
  font-size: 100%;
  font-style: italic;
  margin-bottom: 0.25em;
}
.film .recenzje .stars {
  display: inline;
}
.film .actions {
  margin-top: 0.25em;
}

/filmy/:id

Teraz klikamy w link film_path(film). Popracujemy nad tą stroną. Z szablony mamy coś takiego:

GET /filmy/:id (scaffold)

A powinno to wyglądać jakoś tak:

GET /filmy/:id

TODO

Takie coś nie zadziała:

<% for film in @kino.filmy %>
<h3><%= film.name %></h3>
<p>od <%= film.??? %> / do <%= film.??? %></p>
<% end %>

bo nie wiadomo co wpisać w miejsce ???. Dlatego powinniśmy postąpić tak:

<% for seans in @kino.seanse %>
<h3><%= seans.film.name %></h3>
<p>od <%= seans.starts_on.to_s(:long) %> / do <%= seans.ends_on.to_s(:long) %></p>
<% end %>

Kilka odsyłaczy