Jatkamme sovelluksen rakentamista siitä, mihin jäimme viikon 2 lopussa. Allaoleva materiaali olettaa, että olet tehnyt kaikki edellisen viikon tehtävät. Jos et tehnyt kaikkia tehtäviä, voit täydentää ratkaisusi tehtävien palautusjärjestelmän kautta näkyvän esimerkivastauksen avulla.
Huom: muutamilla on ollut ongelmia Herokun tarvitseman pg-gemin kanssa. Paikallisesti gemiä ei tarvita ja se määriteltiinkin asennettavaksi ainoastaan tuotantoympäristöön. Jos ongelmia ilmenee, voit asentaa gemit antamalla bundle install
-komentoon seuraavan lisämääreen:
bundle install --without production
Tämä asetus muistetaan jatkossa, joten pelkkä bundle install
riittää kun haluat asentaa uusia riippuvuuksia.
Railsia tehtäessä optimaalinen työskentelytapa poikkeaa merkittävästi esim. Java-ohjelmoinnista. Railsia ei yleensä kannata ohjelmoida siten, että editoriin yritetään kirjoittaa paljon valmista koodia, jonka toimivuus sitten testataan menemällä koodin suorittavalle sivulle. Osittain syy tähän on kielen dynaaminen tyypitys ja tulkattavuus, joka tekee parhaillekin IDE:ille ohjelman syntaksin tarkastuksen mahdottomaksi. Toisaalta kielen tulkattavuus ja konsolityökalut (konsoli ja debuggeri) mahdollistavat pienempien koodinpätkien toiminnallisuuden testaamisen ennen niiden siirtämistä editoitavaan kooditiedostoon.
Tarkastellaan esimerkkinä viime viikolla toteutetun oluiden reittausten keskiarvon toteuttamista luontevaa Rails-workflowta noudattaen.
Jokainen olut siis sisältää kokoelman reittauksia:
class Beer < ApplicationRecord
belongs_to :brewery
has_many :ratings
end
Tehtävänämme on luoda oluelle metodi average
class Beer < ApplicationRecord
belongs_to :brewery
has_many :ratings, dependent: :destroy
def average
# code here
end
end
Voisimme toteuttaa keskiarvon laskemisen "javamaisesti" laskemalla summan käymällä reittauksen läpi alkio alkiolta ja jakamalla summan alkioden määrällä.
Kaikki rubyn kokoelmamaiset asiat (mm. taulukko ja has_many
-kenttä) sisältävät Enumerable-moduulin (ks. http://ruby-doc.org/core-2.5.1/Enumerable.html) tarjoamat apumetodit. Päätetäänkin hyödyntää apumetodeja keskiarvon laskemisessa.
Koodin kirjoittamisessa kannattaa ehdottomasti hyödyntää konsolia. Oikeastaan konsoliakin parempi vaihtoehdo on debuggerin käyttö. Debuggerin avulla saadaan avattua konsoli suoraan siihen kontekstiin, johon koodia ollaan kirjoittamassa. Lisätään metodikutsuun debuggerin käynnistävä komento byebug
tai komento binding.pry
jos käytössä on pry-konsoli:
class Beer < ApplicationRecord
belongs_to :brewery
has_many :ratings, dependent: :destroy
def average
binding.pry
end
end
Avataan sitten rails konsoli (eli komento rails c komentoriviltä), luetaan tietokannasta reittauksia sisältävä olio ja kutsutaan sille metodia average
:
[6] pry(main)> b = Beer.first
Beer Load (0.4ms) SELECT "beers".* FROM "beers" ORDER BY "beers"."id" ASC LIMIT ? [["LIMIT", 1]]
=> #<Beer:0x00007fa9e48d35e8
id: 1,
name: "Iso 3",
style: "Lager",
brewery_id: 1,
created_at: Sat, 01 Sep 2018 16:41:53 UTC +00:00,
updated_at: Sat, 01 Sep 2018 16:41:53 UTC +00:00>
> b.average
From: /Users/mluukkai/opetus/ratebeer/app/models/beer.rb @ line 8 Beer#average:
7: def average
=> 8: binding.pry
9: end
>
eli saamme auki debuggerisession, joka avautuu metodin sisälle. Pääsemme siis käsiksi kaikkiin oluen tietoihin.
Olioon itseensä päästään käsiksi viitteellä self
> self
=> #<Beer:0x00007fa9e48d35e8
id: 1,
name: "Iso 3",
style: "Lager",
brewery_id: 1,
created_at: Sat, 01 Sep 2018 16:41:53 UTC +00:00,
updated_at: Sat, 01 Sep 2018 16:41:53 UTC +00:00>
>
ja olioiden kenttiin pistenotaatiolla tai pelkällä kentän nimellä:
> self.name
=> "Iso 3"
> style
=> "Lager"
Huomaa, että jos metodin sisällä on tarkotus muuttaa olion kentän arvoa, on käytettävä pistenotaatiota:
def metodi
# seuraavat komennot tulostavat olion kentän name arvon
puts self.name
puts name
# alustaa metodin sisälle muuttujan name ja antaa sille arvon
name = "StrongBeer"
# muuttaa olion kentän name arvoa
self.name = "WeakBeer"
end
Voimme siis viitata oluen reittauksiin oluen metodin sisältä kentän nimellä ratings
:
> ratings
Rating Load (0.8ms) SELECT "ratings".* FROM "ratings" WHERE "ratings"."beer_id" = ? [["beer_id", 1]]
=> [#<Rating:0x00007fa9dec887e8
id: 1,
score: 21,
beer_id: 1,
created_at: Thu, 06 Sep 2018 14:19:44 UTC +00:00,
updated_at: Thu, 06 Sep 2018 14:19:44 UTC +00:00>,
#<Rating:0x00007fa9e6aab558
id: 2,
score: 18,
beer_id: 1,
created_at: Thu, 06 Sep 2018 14:19:48 UTC +00:00,
updated_at: Thu, 06 Sep 2018 14:19:48 UTC +00:00>,
Katsotaan yksittäistä reittausta:
> ratings.first
=> #<Rating:0x00007fa9dec887e8
id: 1,
score: 21,
beer_id: 1,
created_at: Thu, 06 Sep 2018 14:19:44 UTC +00:00,
updated_at: Thu, 06 Sep 2018 14:19:44 UTC +00:00>
summataksemme reittaukset, tulee siis jokaisesta reittausoliosta ottaa sen kentän score
arvo:
> ratings.first.score
=> 21
Enumerable-modulin metodi map
tarjoaa keinon muodostaa kokoelman perusteella uusi kokoelma, jonka alkiot saadaan alkuperäisen kokelman alkioista, suorittamalla jokaiselle alkiolle mäppäys-funktio.
Jos alkuperäisen kokoelman alkioon viitataan nimellä r
, mäppäysfunktio on yksinkertainen:
> r = ratings.first
> r.score
=> 21
Nyt voimme kokeilla mitä map
tuottaa:
> ratings.map { |r| r.score }
=> [21, 18]
mäppäysfunktio siis annetaan metodille map
parametriksi aaltosulkein erotettuna koodilohkona. Koodilohko voitaisiin erottaa myös do end
-parina, molemmat tuottavat saman lopputuloksen:
> ratings.map do |r| r.score end
=> [21, 18]
>
Metodin map avulla saamme siis muodostettua reittausten kokoelmasta taulukon reittausten arvoja. Seuraava tehtävä on summata nämä arvot.
Rails on lisännyt kaikille Enumerableille metodin sum, kokeillaan sitä mapilla aikansaamaamme taulukkoon.
> ratings.map { |r| r.score }.sum
=> 39
>
Jotta saamme vielä aikaan keskiarvon, on näin saatava summa jaettava alkioiden kokonaismäärällä. Varmistetaan ensin kokonaismäärän laskevan metodin count
tominta:
> ratings.count
=> 2
ja muodostetaan sitten keskiarvon laskeva onelineri:
> ratings.map { |r| r.score }.sum / ratings.count
=> 19
huomaamme että lopputulos pyöristyy väärin. Kyse on tietenkin siitä että sekä jaettava että jakaja ovat kokonaislukuja. Muutetaan toinen näistä liukuluvuksi. Kokeillaan ensin miten kokonaisluvusta liukuluvun tekevä metodi toimii:
> 1.to_f
=> 1.0
Jos et tiedä miten joku asia tehdään Rubyllä, google tietää.
Mieti sopiva hakusana niin saat melko varmasti vastauksen. Kannattaa kuitenkin olla hiukan varovainen ja tutkia ainakin muutama googlen vastaus. Ainakin kannattaa varmistaa että vastauksessa puhutaan riittävän tuoreesta rubyn tai railsin versiosta.
Rubyssä ja Railsissa on useimmiten joku valmis metodi tai gemi melkein kaikkeen, eli pyörän uudelleenkeksimisen sijaan kannattaa aina googlata tai vilkuilla dokumentaatiota.
Muodostetaan sitten lopullinen versio keskiarvon laskevasta koodista:
> ratings.map{ |r| r.score }.sum / ratings.count.to_f
=> 19.5
Nyt koodi on valmis ja testattu, joten se voidaan kopioida metodiin:
class Beer < ApplicationRecord
belongs_to :brewery
has_many :ratings, dependent: :destroy
def average
ratings.map{ |r| r.score }.sum / ratings.count.to_f
end
end
Testataan metodia, eli poistutaan debuggerista (prystä poistutaan komennolla exit ja byebugista komennolla c eli jatkamalla aiemman tyhjän metodin suoritus loppuun), lataamalla uusi koodi, hakemalla olio ja suorittamalla metodi:
> exit
=> nil
> reload!
Reloading...
=> true
> b = Beer.first
> b.average
=> 19.5
Jatkotestaus kuitenkin paljastaa että kaikki ei ole hyvin:
> b = Beer.last
> b.average
=> NaN
eli Hardcore IPA:n reittausten keskiarvo on NaN
. Turvaudutaan jälleen debuggeriin. Laitetaan komento binding.pry
keskiarvon laskevaan metodiin, uudelleenladataan koodi ja kutsutaan metodia ongelmalliselle oliolle:
[3, 12] in /Users/mluukkai/kurssirepot/ratebeer/app/models/beer.rb
3:
4: belongs_to :brewery
5: has_many :ratings, dependent: :destroy
6:
7: def average
8: binding.pry
=> 9: ratings.map{ |r| r.score }.sum / ratings.count.to_f
10: end
11:
12: end
Evaluoidaan lausekkeen osat debuggerissa:
> ratings.map{ |r| r.score }.sum
=> 0
> ratings.count.to_f
=> 0.0
Olemme siis jakamassa kokonaisluku nollaa luvulla nolla, katsotaan mikä laskuoperaation tulos on:
> 0/0.0
=> NaN
eli estääksemme nollalla jakamisen, tulee metodin käsitellä tapaus erikseen:
def average
return 0 if ratings.empty?
ratings.map{ |r| r.score }.sum / ratings.count.to_f
end
Käytämme oneliner-if:iä ja kokoelman metodia empty?
joka evaluoituu todeksi kokoelman ollessa tyhjä. Kyseessä on rubymainen tapa toteuttaa tyhjyystarkastus, joka "javamaisesti" kirjotettuna olisi:
def average
if ratings.count == 0
return 0
end
ratings.map{ |r| r.score }.sum / ratings.count.to_f
end
Kutakin kieltä käytettäessä tulee kuitenkin mukautua kielen omaan tyyliin, varsinkin jos on mukana projekteissa joita ohjelmoi useampi ihminen.
Jos et ole jo rutinoitunut debuggerin käyttöön, kannattaa ehdottomasti kerrata viime viikon debuggeria käsittelevä materiaali.
Jos käytössäsi on Pry komento byebug
ei käyttäydy kaikissa tilanteissa hyvin, kannattaakin käyttää oikeastaan aina komentoa binding.pry
Isommissa ohjelmistoprojekteissa on tapana sopia yhtenäisestä koodityylistä, eli esim. tavoista miten asioita nimetään, mihin aaltosulkeet sijoitetan, missä on välilyönti ja missä ei. Railsin konventiot määrittelevät jo jossain määrin koodin tyyliäkin, lähinnä luokkien ja metodien nimennän tasolla.
Otetaan nyt käyttöön Rubocop, jonka avulla voimme määritellä koodilemme tyylisäännöstön ja seurata, että pidättäydymme säännöstön mukaisessa koodissa. Rubocop on vastaavanlainen staattisen analyysin työkalu kuin Javascript-maailmassa käytetty ESLint tai Javan checkstyle.
Rubocop asennetaan antamalla komentoriviltä komento
gem install rubocop
Rubocopin tarkastama säännöstö määritellään projektin juureen sijoitettavassa tiedostossa .rubocop.yml. Luo tiedosto projektiisi (huomaa, että tiedoston nimen alussa on piste) ja kopioi sille sisältö täältä
Tiedoston määrittelemä säännöstö perustuu Relaxed Ruby -tyyliin, jota se tiukentaa muutamien sääntöjen osalta. Tiedostossa myös jätetään osa projektin tiedostoista tyylitarkastuksen ulkopuolelle.
Tyylitarkastus suoritetaan komentoriviltä komennolla rubocop.
Koodista löytyy melko paljon ongelmia, esim. seuraava:
app/models/beer.rb:8:5: C: Layout/EmptyLineAfterGuardClause: Add empty line after guard clause. return 0 if ratings.empty? ^^^^^^^^^^^^^^^^^^^^^^^^^^
Tiedoston beer.rb rivillä 8 rikotaan sääntöä Layout/EmptyLineAfterGuardClause.
Sääntöjen dokumentaatio selvittää mistä on kyse, eli nyt ongelmana on se, että äsken määrittelemässämme metodissa average ensimmäisen koodirivin, joka on ns. guard clause, jälkeen ei ole tyhjää riviä:
def average
return 0 if ratings.empty?
ratings.map{ |r| r.score }.sum / ratings.count.to_f
end
Seuraava virhe
app/models/concerns/rating_average.rb:9:38: C: Layout/SpaceAroundOperators: Surrounding space missing for operator +. ratings.reduce(0.0){ |sum, r| sum+r.score } / ratings.count ^
taas rikkoo sääntöä, jonka mukaan matemaattisen operaattorin vasemmalla ja oikealla puolella on oltava välilyönti
Ongelmistamme monet liittyvätkin ylimääräisiin tai puuttuviin välilyönteihin tai rivinvaihtoihin:
app/models/concerns/rating_average.rb:10:6: C: Layout/TrailingWhitespace: Trailing whitespace detected. end ^^ app/models/concerns/rating_average.rb:11:1: C: Layout/EmptyLinesAroundModuleBody: Extra empty line detected at module body end. app/models/concerns/rating_average.rb:12:4: C: Layout/TrailingBlankLines: Final newline missing. end app/models/rating.rb:7:1: C: Layout/TrailingWhitespace: Trailing whitespace detected.
Korjaa koodistasi kaikki määrittelymme mukaiset tyylivirheet.
HUOM: voit suorittaa tarkastuksen vain yksittäiselle tiedostolle tai hakemiston sisällölle. Esim. komento rubocop app/models/beer.rb tekee tarkastuksen tiedostolle beer.rb
HUOM2: jos ei suoraan ymmärrä mistä kussakin sääntörikkeessä on kyse, tarkasta asia dokumentaatiosta
Ota käyttöön sääntö, joka estää yli 15 riviä pitkät metodit. Varmista, että rubocop ilmoittaa, jos koodiisi tulee liian pitkä metodi.
Löydät ohjeita säännön määrittelyyn dokumentaation Metrics-osuudesta.
Tästä lähtien kannattaa pitää huoli, että kaikki koodi mitä teet säilyy rubocopin sääntöjen mukaisena. Voit halutessasi muokata konfiguroituja sääntöjä mielesi mukaiseksi.
Laajennetaan sovellusta seuraavaksi siten, että käyttäjien on mahdollista rekisteröidä itselleen järjestelmään käyttäjätunnus. Tulemme hetken päästä muuttamaan toiminnallisuutta myös siten, että jokainen reittaus liittyy sovellukseen kirjautuneena olevaan käyttäjään:
Tehdään käyttäjä ensin pelkän käyttäjätunnuksen omaavaksi olioksi ja lisätään myöhemmin käyttäjälle myös salasana.
Luodaan käyttäjää varten model, näkymä ja kontrolleri komennolla rails g scaffold user username:string
Uuden käyttäjän luominen tapahtuu Rails-konvention mukaan osoitteessa users/new
olevalla lomakkeella. Olisi kuitenkin luontevampaa jos osoite olisi signup
. Lisätään routes.rb:hen vaihtoehtoinen reitti
get 'signup', to: 'users#new'
eli myös osoitteeseen signup tuleva HTTP GET -pyyntö käsitellään Users-kontrollerin metodin new
avulla.
HTTP on tilaton protokolla, eli kaikki HTTP-protokollalla suoritetut pyynnöt ovat toisistaan riippumattomia. Jos Web-sovellukseen kuitenkin halutaan toteuttaa tila, esim. tieto kirjautuneesta käyttäjästä tai ostoskori, tulee jonkinlainen tieto websession "tilasta" välittää jollain tavalla jokaisen selaimen tekemän HTTP-kutsun mukana. Yleisin tapa tilatiedon välittämiseen ovat evästeet, ks. http://en.wikipedia.org/wiki/HTTP_cookie
Lyhyesti sanottuna evästeiden toimintaperiaate on seuraava: kun selaimella mennään jollekin sivustolle, voi palvelin lähettää vastauksessa selaimelle pyynnön evästeen tallettamisesta. Jatkossa selain liittää evästeen kaikkiin sivustolle kohdistuneisiin HTTP-pyyntöihin. Eväste on käytännössä pieni määrä dataa, ja palvelin voi käyttää evästeessä olevaa dataa haluamallaan tavalla evästeen omaavan selaimen tunnistamiseen.
Railsissa sovelluskehittäjän ei ole tarvetta työskennellä suoraan evästeiden kanssa, sillä Railsiin on toteutettu evästeiden avulla hieman korkeammalla abstraktiotasolla toimivat sessiot ks. http://guides.rubyonrails.org/action_controller_overview.html#session joiden avulla sovellus voi "muistaa" tiettyyn selaimeen liittyviä asioita, esim. käyttäjän identiteetin, useiden HTTP-pyyntöjen ajan.
Kokeillaan ensin sessioiden käyttöä muistamaan käyttäjän viimeksi tekemä reittaus. Rails-sovelluksen koodissa HTTP-pyynnön tehneen käyttäjän (tai tarkemmin ottaen selaimen) sessioon pääsee käsiksi hashin kaltaisesti toimivan olion session
kautta.
Talletetaan reittaus sessioon tekemällä seuraava lisäys reittauskontrolleriin:
def create
# otetaan luotu reittaus muuttujaan
rating = Rating.create params.require(:rating).permit(:score, :beer_id)
# talletetaan tehty reittaus sessioon
session[:last_rating] = "#{rating.beer.name} #{rating.score} points"
redirect_to ratings_path
end
jotta edellinen reittaus saadaan näkyviin kaikille sivuille, lisätään application layoutiin (eli tiedostoon app/views/layouts/application.html.erb) seuraava:
<% if session[:last_rating].nil? %>
<p>no ratings given</p>
<% else %>
<p>previous rating: <%= session[:last_rating] %></p>
<% end %>
Kokeillaan nyt sovellusta. Aluksi sessioon ei ole talletettu mitään ja session[:last_rating]
on arvoltaan nil
eli sivulla pitäisi lukea "no ratings given". Tehdään reittaus ja näemme että se tallentuu sessioon. Tehdään vielä uusi reittaus ja havaitsemme että se ylikirjoittaa sessiossa olevan tiedon.
Avaa nyt sovellus incognito-ikkunaan tai toisella selaimella. Huomaat, että toisessa selaimessa session arvo on nil
. Eli sessio on selainkohtainen.
Ideana on toteuttaa kirjautuminen siten, että kirjautumisen yhteydessä talletetaan sessioon kirjautuvaa käyttäjää vastaavan User
-olion id. Uloskirjautuessa sessio nollataan.
Huom: sessioon voi periaatteessa tallennella melkein mitä tahansa olioita, esim. kirjautunutta käyttäjää vastaava User
-olio voitaisiin myös tallettaa sessioon. Hyvänä käytänteenä (ks. http://guides.rubyonrails.org/security.html#session-guidelines) on kuitenkin tallettaa sessioon mahdollisimman vähän tietoa (oletusarvoisesti Railsin sessioihin voidaan tallentaa korkeintaan 4kB tietoa), esim. juuri sen verran, että voidaan identifioida kirjautunut käyttäjä, johon liittyvät muut tiedot saadaan tarvittaessa haettua tietokannasta.
Tehdään nyt sovellukseen kirjautumisesta ja uloskirjautumisesta huolehtiva kontrolleri. Usein Railsissa on tapana noudattaa myös kirjautumisen toteuttamisessa RESTful-ideaa ja konvention mukaisia polkunimiä.
Voidaan ajatella, että kirjautumisen yhteydessä syntyy sessio, ja tätä voidaan pitää jossain mielessä samanlaisena "resurssina" kuin esim. olutta. Nimetäänkin kirjautumisesta huolehtiva kontrolleri SessionsController
iksi
Sessio-resurssi kuitenkin poikkeaa esim. oluista siinä mielessä että tietyllä ajanhetkellä käyttäjä joko ei ole tai on kirjaantuneena. Sessioita ei siis ole yhden käyttäjän näkökulmasta oluiden tapaan useita vaan maksimissaan yksi. Kaikkien sessioiden listaa ei nyt reittien tasolla ole mielekästä olla ollenkaan olemassa kuten esim. oluiden tilanteessa on. Reitit kannattaakin kirjoittaa yksikössä ja tämä saadaan aikaan kun session retit luodaan routes.rb:hen komennolla resource
:
resource :session, only: [:new, :create, :destroy]
HUOM: varmista että kirjoitat määrittelyn routes.rb:hen juuri ylläkuvatulla tavalla, eli resource
, ei resources niinkuin muiden polkujen määrittelyt on tehty.
Kirjautumissivun osoite on nyt session/new. Osoitteeseen session tehty POST-kutsu suorittaa kirjautumisen, eli luo käyttäjälle session. Uloskirjautuminen tapahtuu tuhoamalla käyttäjän sessio eli tekemällä POST-delete kutsu osoitteeseen session.
Tehdään sessioista huolehtiva kontrolleri (tiedostoon app/controllers/sessions_controller.rb):
class SessionsController < ApplicationController
def new
# renderöi kirjautumissivun
end
def create
# haetaan usernamea vastaava käyttäjä tietokannasta
user = User.find_by username: params[:username]
# talletetaan sessioon kirjautuneen käyttäjän id (jos käyttäjä on olemassa)
session[:user_id] = user.id if not user.nil?
# uudelleen ohjataan käyttäjä omalle sivulleen
redirect_to user
end
def destroy
# nollataan sessio
session[:user_id] = nil
# uudelleenohjataan sovellus pääsivulle
redirect_to :root
end
end
Huomaa, että vaikka sessioiden reitit kirjoitetaan nyt yksikössä session ja session/new, on kontrollerin ja näkymien hakemiston kirjoitusasu kuitenkin railsin normaalia monikkomuotoa noudattava.
Kirjautumissivun app/views/sessions/new.html.erb koodi on seuraavassa:
<h1>Sign in</h1>
<%= form_tag session_path do %>
<%= text_field_tag :username, params[:username] %>
<%= submit_tag "Log in" %>
<% end %>
Toisin kuin reittauksille tekemämme formi (kertaa asia viime viikolta), nyt tekemämme lomake ei perustu olioon ja lomake luodaan form_tag
-metodilla, ks. http://guides.rubyonrails.org/form_helpers.html#dealing-with-basic-forms
Lomakkeen lähettäminen siis aiheuttaa HTTP POST -pyynnön session_pathiin (huomaa yksikkömuoto!) eli osoitteeseen session.
Pyynnön käsittelevä metodi ottaa params
-olioon talletetun käyttäjätunnuksen ja hakee sitä vastaavan käyttäjäolion kannasta ja tallettaa olion id:n sessioon jos käyttäjä on olemassa. Lopuksi käyttäjä uudelleenohjataan (kertaa viime viikolta mitä uudelleenohjauksella tarkoitetaan) omalle sivulleen. Kontrollerin koodi vielä uudelleen seuraavassa:
def create
user = User.find_by username: params[:username]
session[:user_id] = user.id if not user.nil?
redirect_to user
end
Huom1: komento redirect_to user
siis on lyhennysmerkintä seuraavalla redirect_to user_path(user)
, ks. viikko 1.
Huom2: Rubyssa yhdistelmän if not
sijaan voidaan käyttää myös komentoa unless
, eli metodin toinen rivi oltaisiin voitu kirjoittaa muodossa
session[:user_id] = user.id unless user.nil?
Paras muoto komennolle on kuitenkin
session[:user_id] = user.id if user
Rubyssä nimittäin kaikki muut arvoit paitsi nil ja false tulkitaan todeksi. Eli nyt komento suoritetaan jos user on jotain muuta kuin nil ja se on täsmälleen haluamamme toiminto.
Lisätään application layoutiin seuraava koodi, joka lisää kirjautuneen käyttäjän nimen kaikille sivuille (edellisessä luvussa lisätyt sessioharjoittelukoodit voi samalla poistaa):
<% if not session[:user_id].nil? %>
<p><%= User.find(session[:user_id]).username %> signed in</p>
<% end %>
menemällä osoitteeseen /session/new voimme nyt kirjautua sovellukseen (olettaen, että sovellukseen on luotu käyttäjiä osoitteesta http://localhost:3000/signup). Uloskirjautuminen ei vielä toistaiseksi onnistu.
HUOM: jos saat virheilmoituksen uninitialized constant SessionsController>
varmista että määrittelit reitit routes.rb:hen oikein, eli
resource :session, only: [:new, :create, :destroy]
Tee kaikki ylläesitetyt muutokset ja varmista, että kirjautuminen onnistuu (eli kirjautunut käyttäjä näytetään sivulla) olemassaolevalla käyttäjätunnuksella (jonka siis voit luoda osoitteessa /signup ). Vaikka uloskirjautuminen ei ole mahdollista, voit kirjautua uudella tunnuksella kirjautumisosoitteessa ja vanha kirjautuminen ylikirjoittuu.
Tietokantakyselyn tekeminen näkymän koodissa (kuten juuri teimme application layoutiin lisätyssä koodissa) on todella ruma tapa. Lisätään luokkaan ApplicationController
seuraava metodi:
class ApplicationController < ActionController::Base
# määritellään, että metodi current_user tulee käyttöön myös näkymissä
helper_method :current_user
def current_user
return nil if session[:user_id].nil?
User.find(session[:user_id])
end
end
Koska kaikki sovelluksen kontrollerit perivät luokan ApplicationController
, on määrittelemämme metodi kaikkien kontrollereiden käytössä. Määrittelimme lisäksi metodin current_user
ns. helper-metodiksi, joten se tulee kontrollerien lisäksi myös kaikkien näkymien käyttöön. Voimme nyt muuttaa application layoutiin lisätyn koodin seuraavaan muotoon:
<% if not current_user.nil? %>
<p><%= current_user.username %> signed in</p>
<% end %>
Voimme muotoilla ehdon myös tyylikkäämmin:
<% if current_user %>
<p><%= current_user.username %> signed in</p>
<% end %>
Pelkkä current_user
toimii ehtona, sillä arvo nil
tulkitaan Rubyssä epätodeksi.
Kirjautumisen osoite sessions/new on hieman ikävä. Määritelläänkin kirjautumista varten luontevampi vaihtoehtoinen osoite signin. Määritellään myös reitti uloskirjautumiselle. Lisätään siis seuraavat routes.rb:hen:
get 'signin', to: 'sessions#new'
delete 'signout', to: 'sessions#destroy'
eli sisäänkirjautumislomake on nyt osoitteessa /signin ja uloskirjautuminen tapahtuu osoitteeseen signout tehtävän HTTP DELETE -pyynnön avulla.
Olisi periaatteessa ollut mahdollista määritellä myös
get 'signout', to: 'sessions#destroy'
eli mahdollistaa uloskirjautuminen HTTP GET:in avulla. Ei kuitenkaan pidetä hyvänä käytänteenä, että HTTP GET -pyyntö tekee muutoksia sovelluksen tilaan ja pysyttäydytään edelleen REST-filosofian mukaisessa käytänteessä, jonka mukaan resurssin tuhoaminen tapahtuu HTTP DELETE -pyynnöllä. Tässä tapauksessa vaan resurssi on hieman laveammin tulkittava asia eli käyttäjän sisäänkirjautuminen.
Muokkaa nyt sovelluksen application layoutissa olevaa navigaatiopalkkia siten, että palkkiin tulee näkyville sisään- ja uloskirjautumislinkit. Huomioi, että uloskirjautumislinkin yhteydessä on määriteltävä käytettäväksi HTTP-metodiksi DELETE, katso esimerkki tähän esim. kaikki käyttäjät listaavalta sivulta.
Edellisten lisäksi lisää palkkiin linkki kaikkien käyttäjien sivulle, sekä kirjautuneen käyttäjän nimi, joka toimii linkkinä käyttäjän omalle sivulle. Käyttäjän ollessa kirjaantuneena tulee palkissa olla myös linkki uuden oluen reittaukseen.
Muistutus: näet järjestelmään määritellyt routet ja polkuapumetodit komentoriviltä komennolla
rails routes
tai menemällä mihin tahansa sovelluksen osoitteeseen, jota ei ole olemassa, esim. http://localhost:3000/wrong
Tehtävän jälkeen sovelluksesi näyttää suunnilleen seuraavalta jos käyttäjä on kirjautuneena:
ja seuraavalta jos käyttäjä ei ole kirjautuneena (huomaa, että nyt näkyvillä on myös uuden käyttäjän rekisteröitymiseen tarkoitettu signup-linkki):
Muutetaan seuraavaksi sovellusta siten, että reittaus kuuluu kirjautuneena olevalle käyttäjälle, eli tämän vaiheen jälkeen olioiden suhteen tulisi näyttää seuraavalta:
Modelien tasolla muutos kulkee tuttuja latuja:
class User < ApplicationRecord
has_many :ratings # käyttäjällä on monta ratingia
end
class Rating < ApplicationRecord
belongs_to :beer
belongs_to :user # rating kuuluu myös käyttäjään
def to_s
"#{beer.name} #{score}"
end
end
Ratkaisu ei kuitenkaan tällaisenaan toimi. Yhteyden takia ratings-tietokantatauluun riveille tarvitaan vierasavaimeksi viite käyttäjän id:hen. Railsissa kaikki muutokset tietokantaan tehdään Ruby-koodia olevien migraatioiden avulla. Luodaan nyt uuden sarakkeen lisäävä migraatio. Generoidaan ensin migraatiotiedosto komentoriviltä komennolla:
rails g migration AddUserIdToRatings
Hakemistoon db/migrate ilmestyy tiedosto, jonka sisältö on seuraava
class AddUserIdToRatings < ActiveRecord::Migration[5.2]
def change
end
end
Huomaa, että hakemistossa on jo omat migraatiotiedostot kaikkia luotuja tietokantatauluja varten. Jokaiseen migraatioon sisällytetään tieto sekä tietokantaan tehtävästä muutoksesta että muutoksen mahdollisesta perumisesta. Jos migraatio on riittävän yksinkertainen, eli sellainen että Rails osaa päätellä suoritettavasta lisäyksestä myös sen peruvan operaation, riittää että migraatiossa on määriteltynä ainoastaan metodi change
. Jos migraatio on monimutkaisempi, on määriteltävä metodit up
ja down
jotka määrittelevät erikseen migraation tekemisen ja sen perumisen.
Tällä kertaa tarvittava migraatio on yksinkertainen:
class AddUserIdToRatings < ActiveRecord::Migration[5.2]
def change
add_column :ratings, :user_id, :integer
end
end
Jotta migraation määrittelemä muutos tapahtuu, suoritetaan komentoriviltä tuttu komento rails db:migrate
Migraatiot ovat varsin laaja aihe ja harjoittelemme niitä vielä lisää myöhemmin kurssilla. Lisää migraatiosta löytyy osoitteesta http://guides.rubyonrails.org/migrations.html
Huomaamme nyt konsolista, että yhteys olioiden välillä toimii:
> u = User.first
> u.ratings
Rating Load (0.3ms) SELECT "ratings".* FROM "ratings" WHERE "ratings"."user_id" = ? [["user_id", 1]]
=> []
Toistaiseksi antamamme reittaukset eivät liity mihinkään käyttäjään:
> r = Rating.first
> r.user
=> nil
>
Päätetään että laitetaan kaikkien olemassaolevien reittausten käyttäjäksi järjestelmään ensimmäisenä luotu käyttäjä:
> u = User.first
> Rating.all.each{ |r| u.ratings << r }
>
HUOM: reittausten tekeminen käyttöliittymän kautta ei toistaiseksi toimi kunnolla, sillä näin luotuja uusia reittauksia ei vielä liitetä mihinkään käyttäjään. Korjaamme tilanteen pian.
Lisää käyttäjän sivulle eli näkymään app/views/users/show.html.erb
- käyttäjän reittausten määrä ja keskiarvo (huom: käytä edellisellä viikolla määriteltyä moduulia
RatingAverage
, jotta saat keskiarvon laskevan koodin käyttäjälle!)- lista käyttäjän reittauksista ja mahdollisuus poistaa reittauksia
Käyttäjän sivu siis näyttää suunilleen seuraavalta:
Reittauksen poisto vie nyt kaikkien reittausten sivulle. Luontevinta olisi, että poiston jälkeen palattaisiin takaisin käyttäjän sivulle. Tee seuraava muutos reittauskontrolleriin, jotta näin tapahtuisi:
def destroy
rating = Rating.find(params[:id])
rating.delete
redirect_to user_path(current_user)
end
Uusien reittausten luominen www-sivulta ei siis tällä hetkellä toimi, koska reittaukseen ei tällä hetkellä liitetä kirjautuneena olevaa käyttäjää. Muokataan siis reittauskontrolleria siten, että kirjautuneena oleva käyttäjä linkitetään luotavaan reittaukseen:
def create
rating = Rating.new params.require(:rating).permit(:score, :beer_id)
rating.user = current_user
rating.save
redirect_to current_user
end
Huomaa, että current_user
on luokkaan ApplicationController
äsken lisäämämme metodi, joka palauttaa kirjautuneena olevan käyttäjän eli suorittaa koodin:
User.find(session[:user_id])
Reittauksen luomisen jälkeen kontrolleri on laitettu uudelleenohjaamaan selain kirjautuneena olevan käyttäjän sivulle.
Muuta sovellusta vielä siten, että kaikkien reittausten sivulla ei ole enää mahdollisuutta reittausten poistoon ja että reittauksen yhteydessä näkyy reittauksen tekijän nimi, joka myös toimii linkkinä reittaajan sivulle.
Kaikkien reittausten sivun tulisi siis näyttää edellisen tehtävän jälkeen seuraavalta:
Tällä hetkellä sovellus käyttäytyy ikävästi, jos kirjautumista yritetään olemassaolemattomalla käyttäjänimellä.
Muutetaan sovellusta siten, että uudelleenohjataan käyttäjä takaisin kirjautumissivulle, jos kirjautuminen epäonnistuu. Eli muutetaan sessiokontrolleria seuraavasti:
def create
user = User.find_by username: params[:username]
if user.nil?
redirect_to signin_path
else
session[:user_id] = user.id
redirect_to user
end
end
muutetaan edellistä vielä siten, että lisätään käyttäjälle kirjautumisen epäonnistuessa, sekä onnistuessa näytettävät viestit:
def create
user = User.find_by username: params[:username]
if user.nil?
redirect_to signin_path, notice: "User #{params[:username]} does not exist!"
else
session[:user_id] = user.id
redirect_to user, notice: "Welcome back!"
end
end
Jotta viesti saadaan näkyville kirjautumissivulle, lisätään näkymään app/views/sessions/new.html.erb
seuraava elementti:
<p id="notice"><%= notice %></p>
Elementti on jo valmiina käyttäjän sivun templatessa (ellet vahingossa poistanut sitä), joten viesti toimii siellä.
Sivulla tarvittaessa näytettävät, seuraavaan HTTP-pyyntöön muistettavat eli uudelleenohjauksenkin yhteydessä toimivat viestit eli flashit on toteutettu Railssissa sessioiden avulla, ks. lisää http://guides.rubyonrails.org/action_controller_overview.html#the-flash
Sovelluksessamme on tällä hetkellä pieni ongelma: on mahdollista luoda useita käyttäjiä, joilla on sama käyttäjätunnus. User-kontrollerin metodissa create
pitäisi siis tarkastaa, ettei username
ole jo käytössä.
Railsiin on sisäänrakennettu monipuolinen mekanismi olioiden kenttien validointiin, ks http://guides.rubyonrails.org/active_record_validations.html ja http://apidock.com/rails/ActiveModel/Validations/ClassMethods
Käyttäjätunnuksen yksikäsitteisyyden validointi onkin helppoa, pieni lisäys User-luokkaan riittää:
class User < ApplicationRecord
include RatingAverage
validates :username, uniqueness: true
has_many :ratings
end
Jos nyt yritetään luoda uudelleen jo olemassaoleva käyttäjä, huomataan että Rails osaa generoida sopivan virheilmoituksen automaattisesti.
Rails (tarkemmin sanoen ActiveRecord) suorittaa oliolle määritellyt validoinnit juuri ennen kuin olio yritetään tallettaa tietokantaan esim. operaatioiden create
tai save
yhteydessä. Jos validointi epäonnistuu, oliota ei tallenneta.
Lisätään saman tien muitakin validointeja sovellukseemme. Lisätään käyttäjälle vaatimus, että käyttäjätunnuksen pituuden on oltava vähintään 3 merkkiä, eli lisätään User-luokkaan rivi:
validates :username, length: { minimum: 3 }
samaa attribuuttia koskevat validointisäännöt voidaan myös yhdistää, yhden validates :attribuutti
-kutsun alle:
class User < ApplicationRecord
include RatingAverage
validates :username, uniqueness: true,
length: { minimum: 3 }
has_many :ratings
end
Railsin scaffold-generaattorilla luodut kontrollerit toimivat siis siten, että jos validointi onnistuu ja olio on tallentunut kantaan, uudelleenohjataan selain luodun olion sivulle. Jos taas validointi epäonnistuu, näytetään uudelleen olion luomisesta huolehtiva lomake ja renderöidään virheilmoitukset lomakkeen näyttävälle sivulle.
Mistä kontrolleri tietää, että validointi on epäonnistunut? Kuten mainittiin, validointi tapahtuu tietokantaan talletuksen yhteydessä. Jos kontrolleri tallettaa olion metodilla save
, voi kontrolleri testata metodin paluuarvosta onko validointi onnistunut:
@user = User.new(parametrit)
if @user.save
# validointi onnistui, uudelleenohjaa selain halutulle sivulle
else
# validointi epäonnistui, renderöi näkymätemplate :new
end
Scaffoldin generoima kontrolleri näyttää hieman monimutkaisemmalta:
def create
@user = User.new(user_params)
respond_to do |format|
if @user.save
format.html { redirect_to @user, notice: 'User was successfully created.' }
format.json { render action: 'show', status: :created, location: @user }
else
format.html { render action: 'new' }
format.json { render json: @user.errors, status: :unprocessable_entity }
end
end
end
Ensinnäkin mistä tulee olion luonnissa parametrina käytettävä user_params
? Huomaamme, että tiedoston alalaitaan on määritelty metodi
def user_params
params.require(:user).permit(:username)
end
eli metodin create
ensimmäinen rivi on siis sama kuin
@user = User.new(params.require(:user).permit(:username))
Entä mitä metodin päättävä respond_to
tekee? Jos olion luonti tapahtuu normaalin lomakkeen kautta, eli selain odottaa takaisin HTML-muotoista vastausta, on toiminnallisuus oleellisesti seuraava:
if @user.save
redirect_to @user, notice: 'User was successfully created.'
else
render action: 'new'
end
eli suoritetaan komentoon (joka on oikeastaan metodi) respond_to
liittyvässä koodilohkossa merkintään (joka on jälleen teknisesti ottaen metodikutsu) format.html
liittyvä koodilohko. Jos taas käyttäjä-olion luova HTTP POST -kutsu olisi tehty siten, että vastausta odotettaisiin json-muodossa (näin tapahtuisi esim. jos pyyntö tehtäisiin toisesta palvelusta tai Web-sivulta javascriptillä), suoritettaisiin format.json
:n liittyvä koodi. Syntaksi saattaa näyttää aluksi oudolta, mutta siihen tottuu pian.
Jatketaan sitten validointien parissa. Määritellään että oluen reittauksen tulee olla kokonaisluku väliltä 1-50:
class Rating < ApplicationRecord
belongs_to :beer
belongs_to :user
validates :score, numericality: { greater_than_or_equal_to: 1,
less_than_or_equal_to: 50,
only_integer: true }
# ...
end
Jos luomme nyt virheellisen reittauksen, ei se talletu kantaan. Huomamme kuitenkin, että emme saa virheilmoitusta. Ongelmana on se, että loimme lomakkeen käsin ja se ei sisällä scaffoldingin yhteydessä automaattisesti generoituvien lomakkeiden tapaan virheraportointia ja että kontrolleri ei tarkista millään tavalla validoinnin onnistumista.
Muutetaan ensin reittaus-kontrollerin metodia create
siten, että validoinnin epäonnistuessa se renderöi uudelleen reittauksen luomisesta huolehtivan lomakkeen:
def create
@rating = Rating.new params.require(:rating).permit(:score, :beer_id)
@rating.user = current_user
if @rating.save
redirect_to user_path current_user
else
@beers = Beer.all
render :new
end
end
Metodissa luodaan siis ensin Rating-olio new
:llä, eli sitä ei vielä talleteta tietokantaan. Tämän jälkeen suoritetaan tietokantaan tallennus metodilla save
. Jos tallennuksen yhteydessä suoritettava olion validointi epäonnistuu, metodi palauttaa epätoden, ja olio ei tallennu kantaan. Tällöin renderöidään new-näkymätemplate. Näkymätemplaten renderöinti edellyttää, että oluiden lista on talletettu muuttujaan @beers
.
Kun nyt yritämme luoda virheellisen reittauksen, käyttäjä pysyy lomakkeen näyttävässä näkymässä (joka siis teknisesti ottaen renderöidään uudelleen POST-kutsun jälkeen). Virheilmoituksia ei kuitenkaan vielä näy.
Validoinnin epäonnistuessa Railsin validaattori tallettaa virheilmoitukset @ratings
olion kenttään @rating.errors
.
Muutetaan lomaketta siten, että lomake näyttää kentän @rating.errors
arvon, jos kenttään on asetettu jotain:
<h2>Create new rating</h2>
<%= form_for(@rating) do |f| %>
<% if @rating.errors.any? %>
<%= @rating.errors.inspect %>
<% end %>
<%= f.select :beer_id, options_from_collection_for_select(@beers, :id, :to_s) %>
score: <%= f.number_field :score %>
<%= f.submit %>
<% end %>
Kun nyt luot virheellisen reittauksen, huomaat että virheen syy selviää kenttään @rating.errors
talletetusta oliosta.
Otetaan sitten mallia esim. näkymätemplatesta views/users/_form.html.erb ja muokataan lomakettamme (views/ratings/new.html.erb) seuraavasti:
<h2>Create new rating</h2>
<%= form_for(@rating) do |f| %>
<% if @rating.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(@rating.errors.count, "error") %> prohibited rating from being saved:</h2>
<ul>
<% @rating.errors.full_messages.each do |msg| %>
<li><%= msg %></li>
<% end %>
</ul>
</div>
<% end %>
<%= f.select :beer_id, options_from_collection_for_select(@beers, :id, :to_s) %>
score: <%= f.number_field :score %>
<%= f.submit %>
<% end %>
Validointivirheitä löytyessä, näkymätemplate renderöi nyt kaikki joukossa @rating.errors.full_messages
olevat virheilmoitukset.
Huom: validoinnin epäonnistuessa ei siis suoriteta uudelleenohjausta (miksi se ei tässä tapauksessa toimi?), vaan renderöidään näkymätemplate, johon tavallisesti päädytään new
-metodin suorituksen yhteydessä.
Apuja seuraaviin tehtäviin löytyy osoitteesta http://guides.rubyonrails.org/active_record_validations.html ja https://apidock.com/rails/v4.2.7/ActiveModel/Validations/ClassMethods/validates
Lisää ohjelmaasi seuraavat validoinnit
- oluen ja panimon nimi on epätyhjä
- panimon perustamisvuosi on kokonaisluku väliltä 1040-2018
- käyttäjätunnuksen eli User-luokan attribuutin username pituus on vähintään 3 mutta enintään 30 merkkiä
Jos yrität luoda oluen tyhjällä nimellä, seurauksena on virheilmoitus:
Mistä tämä johtuu? Jos oluen luonti epäonnistuu validoinnissa tapahtuneen virheen takia, olutkontrollerin metodi create
suorittaa else-haaran, eli renderöi uudelleen oluiden luomiseen käytettävän lomakkeen. Oluiden luomiseen käytettävä lomake käyttää muuttujaan @styles
talletettua oluttyylien listaa lomakkeen generointiin. Virheilmoituksen syynä onkin se, että muuttujaa ei ole nyt alustettu (toisin kuin jos lomakkeeseen mennään kontrollerimetodin new
kautta). Lomake olettaa myös, että muuttujaan @breweries
on talletettu kaikkien panimoiden lista. Eli ongelma korjautuu jos alustamme muuttujat else-haarassa:
def create
@beer = Beer.new(beer_params)
respond_to do |format|
if @beer.save
format.html { redirect_to beers_path, notice: 'Beer was successfully created.' }
format.json { render :show, status: :created, location: @beer }
else
@breweries = Brewery.all
@styles = ["Weizen", "Lager", "Pale ale", "IPA", "Porter"]
format.html { render :new }
format.json { render json: @beer.errors, status: :unprocessable_entity }
end
end
end
tehtävän teko ei ole viikon jatkamisen kannalta välttämätöntä eli ei kannata juuttua tähän tehtävään. Voit tehdä tehtävän myös viikon muiden tehtävien jälkeen.
Parannellaan tehtävän 7 validointia siten, että panimon perustamisvuoden täytyy olla kokonaisluku, jonka suuruus on vähintään 1040 ja korkeintaan menossa oleva vuosi. Vuosilukua ei siis saa kovakoodata.
Huomaa, että seuraava ei toimi halutulla tavalla:
validates :year, numericality: { less_than_or_equal_to: Time.now.year }
Nyt käy siten, että
Time.now.year
evaluoidaan siinä vaiheessa kun ohjelma lataa luokan koodin. Jos esim. ohjelma käynnistetään vuoden 2018 lopussa, ei vuoden 2019 alussa voida rekisteröidä 2019 aloittanutta panimoa, sillä vuoden yläraja validoinnissa on ohjelman käynnistyshetkellä evaluoitunut 2018Eräs kelvollinen ratkaisutapa on oman validointimetodin määritteleminen http://guides.rubyonrails.org/active_record_validations.html#custom-methods
Koodimäärällisesti lyhyempiäkin ratkaisuja löytyy, vihjeenä olkoon lambda/Proc/whatever...
Yhteen olueeseen liittyy monta reittausta, ja reittaus liittyy aina yhteen käyttäjään, eli olueeseen liittyy monta reittauksen tehnyttä käyttäjää. Vastaavasti käyttäjällä on monta reittausta ja reittaus liittyy yhteen olueeseen. Eli käyttäjään liittyy monta reitattua olutta. Oluiden ja käyttäjien välillä on siis monen suhde moneen -yhteys, jossa ratings-taulu toimii liitostaulun tavoin.
Saammekin tuotua tämän many to many -yhteyden kooditasolle helposti käyttämällä jo edellisen viikon lopulta tuttua tapaa, eli has_many through -yhteyttä:
class Beer < ApplicationRecord
include RatingAverage
belongs_to :brewery
has_many :ratings, dependent: :destroy
has_many :users, through: :ratings
# ...
end
class User < ApplicationRecord
include RatingAverage
has_many :ratings
has_many :beers, through: :ratings
# ...
end
Ja monen suhde moneen -yhteys toimii käyttäjästä päin:
User.first.beers
=> [#<Beer:0x00007fbe23b8a770
id: 1,
name: "Iso 3",
style: "Lager",
brewery_id: 1,
created_at: Sat, 01 Sep 2018 16:41:53 UTC +00:00,
updated_at: Sat, 01 Sep 2018 16:41:53 UTC +00:00>,
#<Beer:0x00007fbe23b8a608
id: 1,
# ...
ja oluesta päin:
> Beer.first.users
=> [#<User:0x00007fbe240cab68
id: 1,
username: "hellas",
created_at: Tue, 11 Sep 2018 07:28:39 UTC +00:00,
updated_at: Tue, 11 Sep 2018 07:50:37 UTC +00:00>,
#<User:0x00007fbe240caa28
id: 1,
username: "hellas",
# ...
Vaikuttaa ihan toimivalta, mutta tuntuu hieman kömpeltä viitata oluen reitanneisiin käyttäjiin nimellä users
. Luontevampi viittaustapa oluen reitanneisiin käyttäjiin olisi kenties raters
. Tämä onnistuu vaihtamalla yhteyden määrittelyä seuraavasti
has_many :raters, through: :ratings, source: :user
Oletusarvoisesti has_many
etsii liitettävää taulun nimeä ensimmäisen parametrinsa nimen perusteella. Koska raters
ei ole nyt yhteyden kohteen nimi, on se määritelty erikseen source-option avulla.
Yhteytemme uusi nimi toimii:
> Beer.first.raters
=> [#<User:0x00007fbe240cab68
id: 1,
username: "hellas",
created_at: Tue, 11 Sep 2018 07:28:39 UTC +00:00,
updated_at: Tue, 11 Sep 2018 07:50:37 UTC +00:00>,
#<User:0x00007fbe240caa28
id: 1,
username: "hellas",
# ...
Koska sama käyttäjä voi tehdä useita reittauksia samasta oluesta, näkyy käyttäjä useaan kertaan oluen reittaajien joukossa. Jos haluamme yhden reittaajan näkymään ainoastaan kertaalleen, onnistuu tämä esim. seuraavasti:
> Beer.first.raters.uniq
=> [#<User:0x00007fbe244c4c78
id: 1,
username: "hellas",
created_at: Tue, 11 Sep 2018 07:28:39 UTC +00:00,
updated_at: Tue, 11 Sep 2018 07:50:37 UTC +00:00>]
>
On myös mahdollista määritellä, että oluen raters
palauttaa oletusarvoisesti vain kertaalleen yksittäisen käyttäjän. Tämä onnistuisi asettamalla has_many
-määreelle rajoite distinct, joka rajoittaa niiden olioiden joukkoa, jotka näytetään assosiaatioon liittyviksi siten, että samaa oliota ei näytetä kahteen kertaan:
class Beer < ApplicationRecord
#...
has_many :raters, -> { distinct }, through: :ratings, source: :user
#...
end
Lisää asiaa yhteyksien määrittelemisestä normaaleissa ja hieman monimutkaisemmissa tapauksissa löytyy sivulta https://guides.rubyonrails.org/association_basics.html
Huom: Railsissa on myös toinen tapa many to many -yhteyksien luomiseen has_and_belongs_to_many
ks. http://guides.rubyonrails.org/association_basics.html#the-has-and-belongs-to-many-association jonka käyttö saattaa tulla kyseeseen jos liitostaulua ei tarvita mihinkään muuhun kuin yhteyden muodostamiseen.
Trendinä kuitenkin on, että metodin has_and_belongs_to_many sijaan käytetään (sen monien ongelmien takia) has_many through -yhdistelmää ja eksplisiittisesti määriteltyä yhteystaulua. Mm. Chad Fowler kehottaa kirjassaan Rails recepies välttämään has_and_belongs_to_many:n käyttöä, sama neuvo annetaan Obie Fernandezin autoritiivisessa teoksessa Rails 5 Way
Tämän ja seuraavan tehtävän tekeminen ei ole välttämätöntä viikon jatkamisen kannalta. Voit tehdä tämän tehtävän myös viikon muiden tehtävien jälkeen.
Laajennetaan järjestelmää siten, että käyttäjillä on mahdollista olla eri olutseurojen jäseninä.
Luo scaffoldingia hyväksikäyttäen model
BeerClub
, jolla on attribuutitname
(merkkijono)founded
(kokonaisluku) jacity
(merkkijono)Muodosta
BeerClub
in jaUser
ien välille monen suhde moneen -yhteys. Luo tätä varten liitostauluksi modelMembership
, jolla on attribuutteina vierasavaimetUser
- jaBeerClub
-olioihin (elibeer_club_id
jauser_id
, huomaa miten iso kirjain olion keskellä muuttuu alaviivaksi!). Tämänkin modelin voit luoda scaffoldingilla.Voit toteuttaa tässä vaiheessa jäsenien liittämisen olutseuroihin esim. samalla tavalla kuten oluiden reittaus tapahtuu tällä hetkellä, eli lisäämällä navigointipalkkiin linkin "join a club", jonka avulla kirjautunut käyttäjä voidaan liittää johonkin listalla näytettävistä olutseuroista.
Listaa olutseuran sivulla kaikki jäsenet ja vastaavasti henkilöiden sivulla kaikki olutseurat, joiden jäsen henkilö on. Lisää navigointipalkkiin linkki kaikkien olutseurojen listalle.
Tässä vaiheessa ei ole vielä tarvetta toteuttaa toiminnallisuutta, jonka avulla käyttäjän voi poistaa olutseurasta.
Hio edellisessä tehävässä toteuttamaasi toiminnallisuutta siten, että käyttäjä ei voi liittyä useampaan kertaan samaan olutseuraan.
Tämän tehtävän tekemiseen on monia tapoja, validointien käyttö ei ole välttämättä järkevin tapa tehtävän toteuttamiseen. Liittymislomakkeella tuskin kannattaa edes tarjota sellasia seuroja joiden jäsenenä käyttäjä jo on.
Seuraavat kaksi kuvaa antavat suuntaviivoja sille miltä sovelluksesi voi näyttää tehtävien 9-11 jälkeen.
Muutetaan sovellusta vielä siten, että käyttäjillä on myös salasana. Tietoturvasyistä salasanaa ei tule missään tapauksessa tallentaa tietokantaan. Kantaan talletetaan ainoastaan salasanasta yhdensuuntaisella funktiolla laskettu tiiviste. Tehdään tätä varten migraatio:
rails g migration AddPasswordDigestToUser
migraation (ks. hakemisto db/migrate) koodiksi tulee seuraava:
class AddPasswordDigestToUser < ActiveRecord::Migration[5.2]
def change
add_column :users, :password_digest, :string
end
end
huomaa, että lisättävän sarakkeen nimen on oltava password_digest
.
Tehdään seuraava lisäys luokkaan User
:
class User < ApplicationRecord
include RatingAverage
has_secure_password
# ...
end
has_secure_password
(ks. http://api.rubyonrails.org/classes/ActiveModel/SecurePassword/ClassMethods.html) lisää luokalle toiminnallisuuden, jonka avulla salasanan tiiviste talletetaan kantaan ja käyttäjä voidaan tarpeen vaatiessa autentikoida.
Rails käyttää tiivisteen tallettamiseen bcrypt-ruby
gemiä. Otetaan se käyttöön lisäämällä Gemfile:en rivi
gem 'bcrypt', '~> 3.1.7'
Tämän jälkeen annetaan komentoriviltä komento bundle install
jotta gem asentuu.
Kokeillaan nyt hieman uutta toiminnallisuutta konsolista. Uudelleenkäynnistä konsoli, jotta se saa käyttöönsä uuden gemin. Myös rails-sovellus kannattaa tässä vaiheessa uudelleenkäynnistää. Muista myös suorittaa migraatio!
Salasanatoiminnallisuus has_secure_password
lisää oliolle attribuutit password
ja password_confirmation
. Ideana on, että salasana ja se varmistettuna sijoitetaan näihin attribuutteihin. Kun olio talletetaan tietokantaan esim. metodin save
kutsun yhteydessä, lasketaan tiiviste joka tallettuu tietokantaan olion sarakkeen password_digest
arvoksi. Selväkielinen salasana eli attribuutti password
ei siis tallennu tietokantaan, vaan on ainoastaan olion muistissa olevassa representaatiossa.
HUOM törmäsin seuraavaa tehdessäni virheilmoitukseen
You don't have bcrypt installed in your application. Please add it to your Gemfile and run bundle install LoadError: cannot load such file -- bcrypt from /Users/mluukkai/.rbenv/versions/2.5.1/lib/ruby/gems/2.5.0/gems/bootsnap-1.3.1/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:32:in `require'
Jos näin käy, sulje konsoli, anna komentoriviltä komento spring stop ja käynnistä konsoli uudelleen.
Talletetaan käyttäjälle salasana:
> u = User.first
> u.password = "salainen"
> u.password_confirmation = "salainen"
> u.save
(0.1ms) begin transaction
User Exists (0.2ms) SELECT 1 AS one FROM "users" WHERE "users"."username" = ? AND "users"."id" != ? LIMIT ? [["username", "hellas"], ["id", 1], ["LIMIT", 1]]
User Update (0.4ms) UPDATE "users" SET "updated_at" = ?, "password_digest" = ? WHERE "users"."id" = ? [["updated_at", "2018-09-11 15:39:19.258281"], ["password_digest", "$2a$10$UVsfl5fYQfQDLqI8nsn/Zejpn/dUqzqqhzZ6jDIRzcb7RvArfevIq"], ["id", 1]]
(1.5ms) commit transaction
=> true
>
Autentikointi tapahtuu User
-olioille lisätyn metodin authenticate
avulla seuraavasti:
> u.authenticate "salainen"
=> #<User:0x00007fb9d59ffce0
id: 1,
username: "hellas",
created_at: Tue, 11 Sep 2018 07:28:39 UTC +00:00,
updated_at: Tue, 11 Sep 2018 15:39:19 UTC +00:00,
password_digest: "$2a$10$UVsfl5fYQfQDLqI8nsn/Zejpn/dUqzqqhzZ6jDIRzcb7RvArfevIq">
> u.authenticate "this_is_wrong_password"
=> false
>
eli metodi authenticate
palauttaa false
, jos sille parametrina annettu salasana on väärä. Jos salasana on oikea, palauttaa metodi olion itsensä.
Lisätään nyt kirjautumiseen salasanan tarkistus. Muutetaan ensin kirjautumissivua (app/views/sessions/new.html.erb) siten että käyttäjätunnuksen lisäksi pyydetään salasanaa (huomaa että lomakkeen kentän tyyppi on nyt password_field, joka näyttää kirjoitetun salasanan sijasta ruudulla ainoastaan tähtiä):
<h1>Sign in</h1>
<p id="notice"><%= notice %></p>
<%= form_tag session_path do %>
username <%= text_field_tag :username, params[:username] %>
password <%= password_field_tag :password, params[:password] %>
<%= submit_tag "Log in" %>
<% end %>
ja muutetaan sessions-kontrolleria siten, että se varmistaa metodia authenticate
käyttäen, että lomakkeelta on annettu oikea salasana.
def create
user = User.find_by username: params[:username]
# tarkastetaan että käyttäjä olemassa, ja että salasana on oikea
if user && user.authenticate(params[:password])
session[:user_id] = user.id
redirect_to user_path(user), notice: "Welcome back!"
else
redirect_to signin_path, notice: "Username and/or password mismatch"
end
end
Kokeillaan toimiiko kirjautuminen (huom: jotta bcrypt-gem tulisi sovelluksen käyttöön, käynnistä rails server uudelleen). Kirjautuminen onnistuu toistaiseksi vain niiden käyttäjien tunnuksilla joihin olet lisännyt salasanan konsolista käsin.
Lisätään vielä uuden käyttäjän luomiseen (eli näkymään view/users/_form.html.erb) salasanan syöttökenttä:
<div class="field">
<%= form.label :password %><br />
<%= form.password_field :password %>
</div>
<div class="field">
<%= form.label :password_confirmation %><br />
<%= form.password_field :password_confirmation %>
</div>
Käyttäjien luomisesta huolehtivan kontrollerin apumetodia user_params
on myös muutettava siten, että lomakkeelta lähetettyyn salasanaan ja sen varmenteeseen päästään käsiksi:
def user_params
params.require(:user).permit(:username, :password, :password_confirmation)
end
Kokeile mitä tapahtuu, jos password confirmatioksi annetaan eri arvo kuin passwordiksi.
Huom: jos saat sisäänkirjautumisyrityksessä virheilmoitusen BCrypt::Errors::InvalidHash
johtuu virhe melko varmasti siitä että käyttäjälle ei ole asetettu salasanaa. Eli aseta salasana konsolista ja yritä uudelleen.
Tee luokalle User-validointi, joka varmistaa, että salasanan pituus on vähintää 4 merkkiä, ja että salasana sisältää vähintään yhden ison kirjaimen (voit unohtaa skandit) ja yhden numeron.
Huom: Säännöllisiä lausekkeita voi testailla Rubular sovelluksella: http://rubular.com/ Tehtävän tekeminen onnistuu toki muillakin tekniikoilla.
Tällä hetkellä kuka tahansa voi poistaa kenen tahansa reittauksia. Muutetaan sovellusta siten, että käyttäjä voi poistaa ainoastaan omia reittauksiaan. Tämä onnistuu helposti tarkastamalla asia reittauskontrollerissa:
def destroy
rating = Rating.find params[:id]
rating.delete if current_user == rating.user
redirect_to user_path(current_user)
end
eli tehdään poisto-operaatio ainoastaan, jos current_user
on sama kuin reittaukseen liittyvä käyttäjä.
Reittauksen poistolinkkiä ei oikeastaan ole edes syytä näyttää muuta kuin kirjaantuneen käyttäjän omalla sivulla. Eli muutetaan käyttäjän show-sivua seuraavasti:
<ul>
<% @user.ratings.each do |rating| %>
<li>
<%= rating %>
<% if @user == current_user %>
<%= link_to 'delete', rating, method: :delete, data: { confirm: 'Are you sure?' } %>
<% end %>
</li>
<% end %>
</ul>
Huomaa, että pelkkä delete-linkin poistaminen ei estä poistamasta muiden käyttäjien tekemiä reittauksia, sillä on erittäin helppoa tehdä HTTP DELETE -operaatio mielivaltaisen reittauksen urliin. Tämän takia on oleellista tehdä kirjaantuneen käyttäjän tarkistus poistamisen suorittavassa kontrollerimetodissa.
Kaikkien käyttäjien listalla http://localhost:3000/users on nyt linkki destroy, jonka avulla käyttäjän voi tuhota, sekä linkki edit käyttäjän tietojen muuttamista varten. Poista molemmat linkit sivulta ja lisää ne (oikeastaan deleten siirto riittää, sillä edit on jo valmiina) käyttäjän sivulle.
Näytä editointi- ja tuhoamislinkki vain kirjautuneen käyttäjän itsensä sivulla. Muuta myös User-kontrollerin metodeja
update
jadestroy
siten, että olion tietojen muutosta tai poistoa ei voi tehdä kuin kirjaantuneena oleva käyttäjä itselleen.
Luo uusi käyttäjätunnus, kirjaudu käyttäjänä ja tuhoa käyttäjä. Käyttäjätunnuksen tuhoamisesta seuraa ikävä virhe. Pääset virheestä eroon tuhoamalla selaimesta cookiet. Mieti mistä virhe johtuu ja korjaa asia myös sovelluksesta siten, että käyttäjän tuhoamisen jälkeen sovellus ei joudu virhetilanteeseen.
Tämä tehtävä on vuosien varrella osoittautunut hankalaksi. Jos et pääse eteenpäin, kysy apua pajassa, kurssin telegram-kanavalta, ks. kurssisivu
Laajenna vielä sovellusta siten, että käyttäjän tuhoutuessa käyttäjän tekemät reittaukset tuhoutuvat automaattisesti. Ks. https://github.com/mluukkai/WebPalvelinohjelmointi2018/blob/master/web/viikko2.md#orvot-oliot
Jos teit tehtävät 9-11 eli toteutit järjestelmään olutkerhot, tuhoa käyttäjän tuhoamisen yhteydessä myös käyttäjän jäsenyydet olutkerhoissa
Käyttäjän editointitoiminto mahdollistaa nyt myös käyttäjän username
:n muuttamisen. Tämä ei ole ollenkaan järkevää. Poistetaan tämä mahdollisuus.
Uuden käyttäjän luominen ja käyttäjän editoiminen käyttävät molemmat samaa, tiedostossa views/users/_form.html.erb määriteltyä lomaketta. Alaviivalla alkavat näkymätemplatet ovat Railsissa ns. partiaaleja, joita liitetään muihin templateihin render
-kutsun avulla.
Käyttäjän editointiin tarkoitettu näkymätemplate on seuraavassa:
<h1>Editing user</h1>
<%= render 'form' %>
<%= link_to 'Show', @user %> |
<%= link_to 'Back', users_path %>
eli ensin se renderöi _form-templatessa olevat elementit ja sen jälkeen pari linkkiä. Lomakkeen koodi on seuraava:
<%= form_with(model: user, local: true) do |form| %>
<% if user.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(user.errors.count, "error") %> prohibited this user from being saved:</h2>
<ul>
<% user.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="field">
<%= form.label :username %>
<%= form.text_field :username %>
</div>
<div class="field">
<%= form.label :password %><br />
<%= form.password_field :password %>
</div>
<div class="field">
<%= form.label :password_confirmation %><br />
<%= form.password_field :password_confirmation %>
</div>
<div class="actions">
<%= form.submit %>
</div>
<% end %>
Haluaisimme siis poistaa lomakkeesta seuraavat
<div class="field">
<%= form.label :username %>
<%= form.text_field :username %>
</div>
jos käyttäjän tietoja ollaan editoimassa, eli käyttäjäolio on jo luotu aiemmin.
Lomake voi kysyä oliolta @user
onko se vielä tietokantaan tallentamaton metodin new_record?
avulla. Näin saadaan username
-kenttä näkyville lomakkeeseen ainoastaan silloin kuin kyseessä on uuden käyttäjän luominen:
<% if @user.new_record? %>
<div class="field">
<%= form.label :username %>
<%= form.text_field :username %>
</div>
<% end %>
Nyt lomake on kunnossa, mutta käyttäjänimeä on edelleen mahdollista muuttaa lähettämällä HTTP POST -pyyntö suoraan palvelimelle siten, että mukana on uusi username.
Tehdään vielä User-kontrollerin update
-metodiin tarkastus, joka estää käyttäjänimen muuttamisen:
def update
respond_to do |format|
if user_params[:username].nil? and @user == current_user and @user.update(user_params)
format.html { redirect_to @user, notice: 'User was successfully updated.' }
format.json { head :no_content }
else
format.html { render action: 'edit' }
format.json { render json: @user.errors, status: :unprocessable_entity }
end
end
end
Muutosten jälkeen käyttäjän tietojen muuttamislomake näyttää seuraavalta:
Ainoa käyttäjään liittyvä tieto on nyt salasana, joten muuta käyttäjän tietojen muuttamiseen tarkoitettua lomaketta siten, että se näyttää allaolevassa kuvassa olevalta. Huomaa, että uuden käyttäjän rekisteröitymisen (signup) on edelleen näytettävä samalta kuin ennen.
Kun ohjelman päivitetty versio deployataan herokuun, törmätään jälleen ongelmiin. Kaikkien reittausten ja kaikkien käyttäjien sivu ja signup-linkki saavat aikaan tutun virheen:
Kuten viime viikolla jo totesimme, tulee ongelman syy selvittää herokun lokeista.
Kaikkien käyttäjien sivu aiheuttaa seuraavan virheen:
ActionView::Template::Error (PG::UndefinedTable: ERROR: relation "users" does not exist
eli tietokantataulua users ei ole olemassa koska sovelluksen uusia migraatioita ei ole suoritettu herokussa. Ongelma korjaantuu suorittamalla migraatiot:
heroku run rails db:migrate
Myös signup-sivu toimii migraatioiden suorittamisen jälkeen.
Reittausten sivun ongelma ei korjaantunut migraatioiden avulla ja syytä on etsittävä lokeista:
2018-09-11T16:28:33.610096+00:00 app[web.1]: [2fb11437-8b3c-4ec2-a65c-5f725a7e65b4] ActionView::Template::Error (undefined method `name' for nil:NilClass):
2018-09-11T16:28:33.610221+00:00 app[web.1]: [2fb11437-8b3c-4ec2-a65c-5f725a7e65b4] 2:
2018-09-11T16:28:33.610225+00:00 app[web.1]: [2fb11437-8b3c-4ec2-a65c-5f725a7e65b4] 3: <ul>
2018-09-11T16:28:33.610227+00:00 app[web.1]: [2fb11437-8b3c-4ec2-a65c-5f725a7e65b4] 4: <% @ratings.each do |rating| %>
2018-09-11T16:28:33.610229+00:00 app[web.1]: [2fb11437-8b3c-4ec2-a65c-5f725a7e65b4] 5: <li> <%= rating %> <%= link_to rating.user.username, rating.user %></li>
2018-09-11T16:28:33.610231+00:00 app[web.1]: [2fb11437-8b3c-4ec2-a65c-5f725a7e65b4] 6: <% end %>
2018-09-11T16:28:33.610232+00:00 app[web.1]: [2fb11437-8b3c-4ec2-a65c-5f725a7e65b4] 7: </ul>
2018-09-11T16:28:33.610234+00:00 app[web.1]: [2fb11437-8b3c-4ec2-a65c-5f725a7e65b4] 8:
2018-09-11T16:28:33.610239+00:00 app[web.1]: [2fb11437-8b3c-4ec2-a65c-5f725a7e65b4]
2018-09-11T16:28:33.610241+00:00 app[web.1]: [2fb11437-8b3c-4ec2-a65c-5f725a7e65b4] app/models/rating.rb:10:in `to_s'
Syy on jälleen tuttu, eli näkymäkoodi yrittää kutsua metodia username
nil-arvoiselle oliolle. Syyn täytyy olla link_to
metodissa oleva parametri
rating.user.username
eli järjestelmässä on reittauksia joihin ei liity user-olioa.
Vaikka tietokantamigraatio on suoritettu, on osa järjestelmän datasta edelleen vanhan tietokantaskeeman mukaista. Tietokantamigraation yheyteen olisikin ollut järkevää kirjoittaa koodi, joka varmistaa että myös järjestelmän data saatetaan migraation jälkeen sellaiseen muotoon, mitä koodi olettaa, eli että esim. jokaiseen olemassaolevaan reittaukseen liitetään joku käyttäjä tai käyttäjättömät reittaukset poistetaan.
Luodaan järjestelmään käyttäjä ja laitetaan herokun konsolista kaikkien olemassaolevien reittausten käyttäjäksi järjestelmään ensimmäisenä luotu käyttäjä:
> u = User.first
> Rating.all.each{ |r| u.ratings << r }
Nyt sovellus toimii.
Toistetaan vielä viikon lopuksi edellisen viikon "ongelmia herokussa"-luvun lopetus
Useimmiten tuotannossa vastaan tulevat ongelmat johtuvat siitä, että tietokantaskeeman muutosten takia jotkut oliot ovat joutuneet epäkonsistenttiin tilaan, eli ne esim. viittaavat olioihin joita ei ole tai viitteet puuttuvat. *Sovellus kannattaakin deployata tuotantoon mahdollisimman usein*, näin tiedetään että mahdolliset ongelmat ovat juuri tehtyjen muutosten aiheuttamia ja korjaus on helpompaa.Muista testata rubocopilla, että koodisi noudattaa edelleen määriteltyjä tyylisääntöjä.
Jos käytät Visual Studio Codea, voit asentaa ruby-rubocop laajennuksen, jolloin editori huomauttaa heti jos teet koodiin tyylivirheen:
Commitoi kaikki tekemäsi muutokset ja pushaa koodi Githubiin. Deployaa myös uusin versio Herokuun.
Tehtävät kirjataan palautetuksi osoitteeseen https://studies.cs.helsinki.fi/courses/#/rails2018