วันพุธที่ 4 กุมภาพันธ์ พ.ศ. 2558

Chapter 5. Saving User Input

Wiring Up Our Form to Send a POST Request

จากบทที่แล้วเราไม่สามารถบันทึกค่า input ของผู้ใช้ได้


ที่ lists/templates/home.html ให้เพิ่ม form เข้าไปครอบ input

<h1>Your To-Do list</h1>
<form method="POST">
    <input name="item_text" id="id_new_item" placeholder="Enter a to-do item" />
</form>

<table id="id_list_table">




เมื่อการTestล้มแหลวแบบนึกไม่ถึง มีหลายวิธ๊ที่จะแก้ไขดังนี้
     - ให้เพิ่ม print statements เพื่อดูข้อความerrorที่โปรแกรมบอก
     - ปรับปรุงข้อผิดพลาดนั้น
     - ดูเว็บไซต์ ด้วยตัวเอง
     - ใช้คำสั่ง time.sleep เพื่อหยุดการทดสอบระหว่างการทำงาน

functional_tests.py เพิ่ม 
[...]
    inputbox.send_keys(Keys.ENTER)

    import time
    time.sleep(10)

    table = self.browser.find_element_by_id('id_list_table')

[...]


เกิด Error เนื่องจากการตรวจสอบ CSRF ล้มเหลว


lists/templates/home.html

<form method="POST">
    <input name="item_text" id="id_new_item" placeholder="Enter a to-do item" />
    {% csrf_token %}
</form>


การใส่ {% csrf_token %} ลงไปจะเปรียบเสมือนเรากำหนดค่า<input type="hidden"> ทำให้เราสามารถ render หน้าเว็บได้

เนื่องจากเรายังมีคำสั่ง time.sleep อยู่เราจะเห็นว่า web page ที่เราเรียกนั้นไม่มีปัญหาแล้ว



จากนั้นให้ลบคำสั่ง time.sleep ออกได้เลย

Processing a POST Request on the Server

ทดสอบดูว่า home.html จะสามารถบันทึกค่า POST Requestได้หรือไม่ lists/tests.py เพิ่ม code ข้างล่างเข้าไป

from django.template.loader import render_to_string
from django.core.urlresolvers import resolve
from django.test import TestCase
from django.http import HttpRequest
from lists.views import home_page

class HomePageTest(TestCase):

    def test_root_url_resolves_to_home_page_view(self):
        found = resolve('/')
        self.assertEqual(found.func, home_page)


    def test_home_page_returns_correct_html(self):
        request = HttpRequest()
        response = home_page(request)
        expected_html = render_to_string('home.html')
        self.assertEqual(response.content.decode(), expected_html)

    def test_home_page_can_save_a_POST_request(self):
        request = HttpRequest()
        request.method = 'POST'
        request.POST['item_text'] = 'A new list item'

        response = home_page(request)

        self.assertIn('A new list item', response.content.decode())



 


เราต้องไปสร้าง code path สำหรับ POST Request 

ที่ lists/views.py

from django.http import HttpResponse
from django.shortcuts import render

def home_page(request):
    if request.method == 'POST':
        return HttpResponse(request.POST['item_text'])
    return render(request, 'home.html')


Passing Python Variables to Be Rendered in the Template

การแสดงชื่อตัวแปรใน template จะใช้สัญลักษณ์ {{ ... }}

lists/templates/home.html


[...]
<body>
    <h1>Your To-Do list</h1>
    <form method="POST">
        <input name="item_text" id="id_new_item" placeholder="Enter a to-do item" />
        {% csrf_token %}
    </form>

    <table id="id_list_table">
        <tr><td>
{{ new_item_text }}</td></tr>
    </table>
</body>

[...]


จากนั้นจะใช้ Function render_to_string โดย parameter ตัวแรกคือ template ส่วนตัวที่สองเป็นการ map กันระหว่างชื่อตัวแปรกับค่าของตัวแปร
lists/tests.py เพิ่ม code ส่วนล่างเข้าไป

[...]     
    self.assertIn('A new list item', response.content.decode())
    expected_html = render_to_string(
        'home.html',
        {'new_item_text':  'A new list item'}
    )
    self.assertEqual(response.content.decode(), expected_html)





จากนั้นไปแก้ lists/views.py

def home_page(request):
    return render(request, 'home.html', {
        'new_item_text': request.POST['item_text'],
    })




เกิด KeyError ขึ้น แก้ไขโดยใช้คำสั่ง .get() ไปแก้ที่ lists/views.py

def home_page(request):
    return render(request, 'home.html', {
        'new_item_text':
request.POST.get('item_text', ''),
    })



ยังไม่มี item ใน table

เราต้องการให้ error แสดงรายละเอียดมากกว่านี้ โดยใช้เทคนิค FT debugging โดยแสดงค่าที่มีอยู่ใน table functional_tests.py

[...]
    self.assertTrue(
        any(row.text == '1: Buy peacock feathers' for row in rows),
        "New to-do item did not appear in table -- its text was:\n%s" % (
            table.text,
        )
    )

[...]




ที่ functional_tests.py ใส่ code ด้านล่างแทน self.assertTrue อันเก่าที่มี 6 บรรทัด

self.assertIn('1: Buy peacock feathers', [row.text for row in rows]) 



เนื่องจากใน functional_tests นั้นเราใส่เลข 1 แต่ใน template ของเรานั้นไม่มีเลข 1
lists/templates/home.html เพิ่มเลข 1: เข้าไป เพราะ FT ต้องการให้เราระบุเลข 1 ที่จุดเริ่มต้นของรายการ

    <tr><td>1: {{ new_item_text }}</td></tr>


เมื่อลองรันดูก็จะผ่าน



ที่นี้ลองเพิ่มการส่งค่าให้ template โดยใช้การ copy and paste
functional_tests.py

from selenium import webdriver
from selenium.webdriver.common.keys import Keys
import unittest

class NewVisitorTest(unittest.TestCase):

    def setUp(self):
        self.browser = webdriver.Firefox()
        self.browser.implicitly_wait(3)

    def tearDown(self):
        self.browser.quit()

    def test_can_start_a_list_and_retrieve_it_later(self):
        # Edith has heard about a cool new online to-do app. She goes
        # to check out its homepage
        self.browser.get('http://localhost:8000')

        # She notices the page title and header mention to-do lists
        self.assertIn('To-Do', self.browser.title)
        header_text = self.browser.find_element_by_tag_name('h1').text
        self.assertIn('To-Do', header_text)

        # She is invited to enter a to-do item straight away
        inputbox = self.browser.find_element_by_id('id_new_item')
        self.assertEqual(
                inputbox.get_attribute('placeholder'),
                'Enter a to-do item'
        )

        # She types "Buy peacock feathers" into a text box (Edith's hobby
        # is tying fly-fishing lures)
        inputbox.send_keys('Buy peacock feathers')

        # When she hits enter, the page updates, and now the page lists
        # "1: Buy peacock feathers" as an item in a to-do list table
        inputbox.send_keys(Keys.ENTER)
        table = self.browser.find_element_by_id('id_list_table')
        rows = table.find_elements_by_tag_name('tr')
        self.assertIn('1: Buy peacock feathers', [row.text for row in rows])

        # There is still a text box inviting her to add another item. She
        # enters "Use peacock feathers to make a fly" (Edith is very
        # methodical)
        inputbox = self.browser.find_element_by_id('id_new_item')
        inputbox.send_keys('Use peacock feathers to make a fly')
        inputbox.send_keys(Keys.ENTER)
 
        # The page updates again, and now shows both items on her list
        table = self.browser.find_element_by_id('id_list_table')
        rows = table.find_elements_by_tag_name('tr')
        self.assertIn('1: Buy peacock feathers', [row.text for row in rows])
        self.assertIn(
           '2: Use peacock feathers to make a fly' ,
            [row.text for row in rows]
        )

        # Edith wonders whether the site will remember her list. Then she sees
        # that the site has generated a unique URL for her -- there is some
        # explanatory text to that effect.
        self.fail('Finish the test!')

        # She visits that URL - her to-do list is still there.

if __name__ == '__main__':
    unittest.main(warnings='ignore')




เนื่องจาก temaplate ยังไม่รองรับเลข 2


Three Strikes and Refactor

ไม่ควรใช้วิธีแก้ copy and paste แบบใน FT ควรใช้ function หากอยู่ในไฟล์เดียวกัน หรือใช้ import ในกรณีที่อยู่คนละไฟล์


ทำการ refactor functional_tests.py

from selenium import webdriver
from selenium.webdriver.common.keys import Keys
import unittest

class NewVisitorTest(unittest.TestCase):

    def setUp(self):
        self.browser = webdriver.Firefox()
        self.browser.implicitly_wait(3)

    def tearDown(self):
        self.browser.quit()

    def check_for_row_in_list_table(self, row_text):
        table = self.browser.find_element_by_id('id_list_table')
        rows = table.find_elements_by_tag_name('tr')
        self.assertIn(row_text, [row.text for row in rows])


    def test_can_start_a_list_and_retrieve_it_later(self):
        # Edith has heard about a cool new online to-do app. She goes
        # to check out its homepage
        self.browser.get('http://localhost:8000')

        # She notices the page title and header mention to-do lists
        self.assertIn('To-Do', self.browser.title)
        header_text = self.browser.find_element_by_tag_name('h1').text
        self.assertIn('To-Do', header_text)

        # She is invited to enter a to-do item straight away
        inputbox = self.browser.find_element_by_id('id_new_item')
        self.assertEqual(
                inputbox.get_attribute('placeholder'),
                'Enter a to-do item'
        )

        # She types "Buy peacock feathers" into a text box (Edith's hobby
        # is tying fly-fishing lures)
        inputbox.send_keys('Buy peacock feathers')

        # When she hits enter, the page updates, and now the page lists
        # "1: Buy peacock feathers" as an item in a to-do list table
        inputbox.send_keys(Keys.ENTER)
        self.check_for_row_in_list_table('1: Buy peacock feathers')

        # There is still a text box inviting her to add another item. She
        # enters "Use peacock feathers to make a fly" (Edith is very
        # methodical)
        inputbox = self.browser.find_element_by_id('id_new_item')
        inputbox.send_keys('Use peacock feathers to make a fly')
        inputbox.send_keys(Keys.ENTER)

        # The page updates again, and now shows both items on her list
        self.check_for_row_in_list_table('1: Buy peacock feathers')
        self.check_for_row_in_list_table('2: Use peacock feathers to make a fly')

        # Edith wonders whether the site will remember her list. Then she sees
        # that the site has generated a unique URL for her -- there is some
        # explanatory text to that effect.
        self.fail('Finish the test!')

        # She visits that URL - her to-do list is still there.
if __name__ == '__main__':
    unittest.main(warnings='ignore')

 

error ยังคงเหมือนเดิมคือ template ยังไม่รองรับเลข 2

 

The Django ORM and Our First Model

สร้าง database  ด้วย Django ORM

lists/tests.py

from lists.models import Item
[...]

class ItemModelTest(TestCase):

    def test_saving_and_retrieving_items(self):
        first_item = Item()
        first_item.text = 'The first (ever) list item'
        first_item.save()

        second_item = Item()
        second_item.text = 'Item the second'
        second_item.save()

        saved_items = Item.objects.all()
        self.assertEqual(saved_items.count(), 2)

        first_saved_item = saved_items[0]
        second_saved_item = saved_items[1]
        self.assertEqual(first_saved_item.text, 'The first (ever) list item')
        self.assertEqual(second_saved_item.text, 'Item the second')

 

เนื่องจากเรายังไม่มี item จึงต้องไปสร้าง item ที่เป็น model

lists/models.py

from django.db import models

class Item(object):
    pass

 

ไม่มี attribute save เราจึงสืบทอดคลาสมาจาก Model

lists/models.py

from django.db import models

class Item(models.Model):
    pass 

 

Our First Database Migration

เมื่อลองรัน test ดู

เนื่องจากการสร้าง ORM เป็นแค่การ model Database ยังไม่เป็น Database จริงๆ



โดยคำสั่ง python3 manage.py makemigrations จะสร้างไฟล์ 0001_initial.py ขึ้นมา เป็น Migration เริ่มต้น

The Test Gets Surprisingly Far

ลองทดสอบดู

เนื่องจาก Django ไม่รู้ว่าเรามี table ที่เก็บ text ของเราไว้เราจึงต้องสร้าง text ใหม่ของเราขึ้นมาเอง


lists/models.py

class Item(models.Model):
    text = models.TextField()


A New Field Means a New Migration


เมื่อทดสอบรันดูก็จะเกิด Error ขึ้นว่า

django.db.utils.OperationalError: no such column: lists_item.text

เพราะว่าเราได้ไปเพิ่ม field ใหม่ใน database ของเราซึ่งหมายความว่าเราต้องสร้าง Migration ใหม่สำหรับ field ใหม่ด้วย

ให้เลือก 2 เพื่อเพิ่มค่าเริ่มต้นของ field ไปใน models.py

lists/models.py


class Item(models.Model):
    text = models.TextField(default='')

 










ที่นี้เราก็จะได้ไฟล์ 0002_item_text.py ขึ้นมา


$ git status
$ git diff
$ git add lists
$ git commit -m "Model for list Items and associated migration"


Saving the POST to the Database


ก่อนอื่นเรามาปรับปรุง code ของเราโดยการเพิ่ม 3 บรรทัดใหม่เข้าไป



lists/tests.py



def test_home_page_can_save_a_POST_request(self):

    request = HttpRequest()

    request.method = 'POST'

    request.POST['item_text'] = 'A new list item'



    response = home_page(request)



    self.assertEqual(Item.objects.count(), 1)  #1

    new_item = Item.objects.first()  #2

    self.assertEqual(new_item.text, 'A new list item')  #3


    self.assertIn('A new list item', response.content.decode())
    expected_html = render_to_string(
        'home.html',
        {'new_item_text':  'A new list item'}
    )
    self.assertEqual(response.content.decode(), expected_html)

1 เช็คว่า item ถูกบันทึกลง database.objects.count()ซึ่งเป็นตัวย่อของ objects.all().count()

2 objects.first() เป็นเหมือน objects.all()[0].

3 เช็คว่าค่าที่รับมานั้นถูกต้องหรือไม่


เมื่อลองรันดู



lists/views.py. 

from django.shortcuts import render
from lists.models import Item

def home_page(request):
    item = Item()
    item.text = request.POST.get('item_text', '')
    item.save()

    return render(request, 'home.html', {
        'new_item_text': request.POST.get('item_text', ''),
    })



หลังจากนั้นทำการปรับปรุงแก้ไข code

lists/views.py. 

    return render(request, 'home.html', {
        'new_item_text': item.text
    })

Let’s have a little look at our scratchpad. I’ve added a couple of the other things that are on our mind:




Let’s start with the first one. We could tack on an assertion to an existing test, but it’s best to keep unit tests to testing one thing at a time, so let’s add a new one:

lists/tests.py

class HomePageTest(TestCase):
    [...]

    def test_home_page_only_saves_items_when_necessary(self):
        request = HttpRequest()
        home_page(request)
        self.assertEqual(Item.objects.count(), 0)


lists/views.py. 

def home_page(request):
    if request.method == 'POST':
        new_item_text = request.POST['item_text']  #1
        Item.objects.create(text=new_item_text)  #2
    else:
        new_item_text = ''  #3

    return render(request, 'home.html', {
        'new_item_text': new_item_text,  #4
    })
1 3 4 ใช้ตัวแปร new_item_text เพื่อเก็บค่า POST หรือ ให้เป็นคำว่าง

2 .objects.create ใช้สร้างรายการใหม่โดยไม่ต้องเรียกใช้ .save()




Redirect After a POST

หลังจากเรารับค่า POST มาแล้ว มันควรจะส่งค่ากลับไปยัง home page แทนที่จะ render ออกมา
lists/tests.py

    def test_home_page_can_save_a_POST_request(self):
        request = HttpRequest()
        request.method = 'POST'
        request.POST['item_text'] = 'A new list item'

        response = home_page(request)

        self.assertEqual(Item.objects.count(), 1)
        new_item = Item.objects.first()
        self.assertEqual(new_item.text, 'A new list item')

        self.assertEqual(response.status_code, 302)
        self.assertEqual(response['location'], '/')

การเปลี่ยนเส้นทางควรมี code 302 และชี้ browser ไปยังที่อยู่ใหม่

lists/views.py

from django.shortcuts import redirect, render
from lists.models import Item

def home_page(request):
    if request.method == 'POST':
        Item.objects.create(text=request.POST['item_text'])
        return redirect('/')

    return render(request, 'home.html')

ลองทดสอบรันอีกครั้ง





Better Unit Testing Practice: Each Test Should Test One Thing

จากที่หนังสือได้บอกว่า Unit Test ควรสั้นและแยกเป็นส่วนๆ เพื่อให้ง่ายต่อการอ่านและการแก้ไข bug

lists/tests.py

    def test_home_page_can_save_a_POST_request(self):
        request = HttpRequest()
        request.method = 'POST'
        request.POST['item_text'] = 'A new list item'

        response = home_page(request)

        self.assertEqual(Item.objects.count(), 1)
        new_item = Item.objects.first()
        self.assertEqual(new_item.text, 'A new list item')


    def test_home_page_redirects_after_POST(self):
        request = HttpRequest()
        request.method = 'POST'
        request.POST['item_text'] = 'A new list item'

        response = home_page(request)

        self.assertEqual(response.status_code, 302)
        self.assertEqual(response['location'], '/')

เมื่อทดลองรันดู

 
Rendering Items in the Template

เราจะทำการ check ว่า template สามารถแสดงรายชื่อ item ได้หรือไม่


lists/tests.py

class HomePageTest(TestCase):
    [...]

    def test_home_page_displays_all_list_items(self):
        Item.objects.create(text='itemey 1')
        Item.objects.create(text='itemey 2')

        request = HttpRequest()
        response = home_page(request)

        self.assertIn('itemey 1', response.content.decode())
        self.assertIn('itemey 2', response.content.decode())


แก้ template ให้แสดงผลได้หลายๆแถว โดยใช้ คำสั่ง {% for .. in .. %}

lists/templates/home.html

<table id="id_list_table">
    {% for item in items %}
        <tr><td>1: {{ item.text }}</td></tr>
    {% endfor %}
</table>

หลังจากนั้นไปแก้ที่ lists/views.py

def home_page(request):
    if request.method == 'POST':
        Item.objects.create(text=request.POST['item_text'])
        return redirect('/')

    items = Item.objects.all()
    return render(request, 'home.html', {'items': items})


เมื่อรัน Unit test ก็จะผ่าน แต่รัน FT ไม่ผ่าน


 
โดยในหนังสือจะมีเทคนิคการแก้ ด้วยการเข้าไปใน http://localhost:8000



Creating Our Production Database with migrate

เราต้องทำการสร้าง database ของจริงขึ้นมาเพราะ database ที่เราใช้นั้นเป็น database พิเศษ สำหรับใช้ test

superlists/settings.py

[...]
# Database
# https://docs.djangoproject.com/en/1.7/ref/settings/#databases

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
    }
}


We’ve told Django everything it needs to create the database, first via models.py and then when we created the migrations file. To actually apply it to creating a real database, we use another Django Swiss Army knife manage.py command, migrate:




Now we can refresh the page on localhost, see that our error is gone, and try running the functional tests again:[6]




ใกล้เคียงความถูกต้องแล้ว เหลือแค่กำหนดตัวเลขให้ถูกต้อง

lists/templates/home.html

    {% for item in items %}
        <tr><td>{{ forloop.counter }}: {{ item.text }}</td></tr>
    {% endfor %}

ลองทดสอบดูอีกครั้ง





ถ้าต้องการลบและสร้าง database ใหม่ให้ใช้คำสั่ง

$ rm db.sqlite3
$ python3 manage.py migrate --noinput

$ git add lists
$ git commit -m "Redirect after POST, and show all items in template"

ไม่มีความคิดเห็น:

แสดงความคิดเห็น