Skip to content

Latest commit

 

History

History
1538 lines (1121 loc) · 68.6 KB

viikko3.md

File metadata and controls

1538 lines (1121 loc) · 68.6 KB

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.

Rails-ohjelmoijan workflow

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.

byebug vai binding.pry deguggaukseen?

Jos käytössäsi on Pry komento byebug ei käyttäydy kaikissa tilanteissa hyvin, kannattaakin käyttää oikeastaan aina komentoa binding.pry

Rubocop: tyyli ratkaisee

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.

Tehtävä 1

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

Tehtävä 2

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.

Käyttäjä ja sessio

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:

mvc-kuva

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.

Kirjautuminen

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 SessionsControlleriksi

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]

Tehtävä 3

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.

Kontrollerien ja näkymien apumetodi

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.

Tehtävä 4

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:

kuva

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):

kuva

Reittaukset käyttäjälle

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:

kuva

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.

Tehtävä 5

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:

kuva

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.

Tehtävä 6

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:

kuva

Kirjautumisen hienosäätöä

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

Olioiden kenttien validointi

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

Tehtävä 7

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:

kuva

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ä 8

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 2018

Erä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...

Monen suhde moneen -yhteydet

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

Tehtävät 9-10: Olutseurat

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 attribuutit name (merkkijono) founded (kokonaisluku) ja city (merkkijono)

Muodosta BeerClubin ja Userien välille monen suhde moneen -yhteys. Luo tätä varten liitostauluksi model Membership, jolla on attribuutteina vierasavaimet User- ja BeerClub-olioihin (eli beer_club_id ja user_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.

Tehtävä 11

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.

kuva

kuva

Salasana

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.

Tehtävä 12

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.

Vain omien reittausten poisto

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.

Tehtävä 13

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 ja destroy siten, että olion tietojen muutosta tai poistoa ei voi tehdä kuin kirjaantuneena oleva käyttäjä itselleen.

Tehtävä 14

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

Tehtävä 15

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

Lisää hienosäätöä

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:

kuva

Tehtävä 16

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.

kuva

Ongelmia herokussa

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:

kuva

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.

Rubocop

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:

kuva

Tehtävien palautus

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