فرم ها در جنگو

سلام

کاربرد فرم ها که معلوم است اما مشکلی که در نرم افزارهای تحت وب وجود دارد مربوط به پیاده سازی ناصحیح فرم ها و عدم کنترل و پاکسازی ورودی‌ها بوده و ممکن است باعث حملاتی مثل SQLi یا XSS یا نظیر آن شود. اما در صورتی که در جنگو به صورت استاندارد کد زده شود این مشکل وجود نخواهد داشت.

فرمی که قرار است پیاده سازی شود به شکل زیر است:

دو ملاحظه برای پیاده سازی این فرم وجود دارد:

  • اعتبارسنجی کاربران: این فرم قرار است فقط به کاربران مجاز و بعد از ورود به سیستم به ایشان نمایش داده شود. که در این مرحله فرض می کنیم که این کار انجام شده است.
  • در این فرم از دو مدل متفاوت استفاده شده. یکی مدل تاپیک برای فیلد «subject» و دیگری مدل پست برای فیلد «message»

خوب. برای شروع باید یک URL با نام new_topic بسازیم.

myproject/urls.py

from django.conf.urls import url
from django.contrib import admin

from boards import views

urlpatterns = [
    url(r'^$', views.home, name='home'),
    url(r'^boards/(?P<pk>\d+)/$', views.board_topics, name='board_topics'),
    url(r'^boards/(?P<pk>\d+)/new/$', views.new_topic, name='new_topic'),
    url(r'^admin/', admin.site.urls),
]

تابع ویوی new_topic به شکل زیر می شود:

boards/views.py

from django.shortcuts import render, get_object_or_404
from .models import Board

def new_topic(request, pk):
    board = get_object_or_404(Board, pk=pk)
    return render(request, 'new_topic.html', {'board': board})

در پیاده سازی فعلی، تابع جدید دقیقا مشابه تابع board_topics است.

بر اساس این تابع، باید تمپلیتی را با نام new_topic.html ایجاد کنیم:

templates/new_topic.html

{% extends 'base.html' %}

{% block title %}Start a New Topic{% endblock %}

{% block breadcrumb %}
  <li class="breadcrumb-item"><a href="{% url 'home' %}">Boards</a></li>
  <li class="breadcrumb-item"><a href="{% url 'board_topics' board.pk %}">{{ board.name }}</a></li>
  <li class="breadcrumb-item active">New topic</li>
{% endblock %}

{% block content %}

{% endblock %}

تا اینجای کار یک بلوک را برای ساخت فرم ایجاد کردیم و همچنین نام board.name به صورت لینکی در آمده تا به صفحۀ مربوط به تاپیک‌ها ارجاع یابد.

آدرس http://127.0.0.1:8000/boards/1/new/ را وارد می کنیم.

توجه شود که هنوز صفحه ای را نساخته ایم که در آن لینکی به این صفحۀ جدید وجود داشته باشد اما اگر به صورت دستی آدرس را وارد کنیم به آن صفحه انتقال خواهیم یافت.

و حالا باید برای آن فایل تست بنویسیم.

boards/tests.py

from django.core.urlresolvers import reverse
from django.urls import resolve
from django.test import TestCase
from .views import home, board_topics, new_topic
from .models import Board

class HomeTests(TestCase):
    # ...

class BoardTopicsTests(TestCase):
    # ...

class NewTopicTests(TestCase):
    def setUp(self):
        Board.objects.create(name='Django', description='Django board.')

    def test_new_topic_view_success_status_code(self):
        url = reverse('new_topic', kwargs={'pk': 1})
        response = self.client.get(url)
        self.assertEquals(response.status_code, 200)

    def test_new_topic_view_not_found_status_code(self):
        url = reverse('new_topic', kwargs={'pk': 99})
        response = self.client.get(url)
        self.assertEquals(response.status_code, 404)

    def test_new_topic_url_resolves_new_topic_view(self):
        view = resolve('/boards/1/new/')
        self.assertEquals(view.func, new_topic)

    def test_new_topic_view_contains_link_back_to_board_topics_view(self):
        new_topic_url = reverse('new_topic', kwargs={'pk': 1})
        board_topics_url = reverse('board_topics', kwargs={'pk': 1})
        response = self.client.get(new_topic_url)
        self.assertContains(response, 'href="{0}"'.format(board_topics_url))

مروری کوتاه بر عناوین تست‌ها!

  • setUp: یک بورد به صورت موقت می سازد تا از آن در طول برنامۀ تست استفاده کند.
  • test_new_topic_view_success_status_code: چک می کند که آیا درست است یا خیر؟
  • test_new_topic_view_not_found_status_code: در صورت خطا کد ۴۰۴ را باز می گرداند.
  • test_new_topic_url_resolves_new_topic_view: بررسی می کند که ویوی درستی استفاده شده باشد.
  • test_new_topic_view_contains_link_back_to_board_topics_view: چک می کند که لینک برگشت به لیست تاپیک ها درست کار کند.

حالا نوبت به اجرای تست می شود:

python manage.py test

این هم از خروجی:

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...........
-------------------------------------
Ran 11 tests in 0.076s

OK
Destroying test database for alias 'default'...

بعد از انجام تست، می خواهیم فرم خود را طراحی کنیم:

templates/new_topic.html

{% extends 'base.html' %}

{% block title %}Start a New Topic{% endblock %}

{% block breadcrumb %}
  <li class="breadcrumb-item"><a href="{% url 'home' %}">Boards</a></li>
  <li class="breadcrumb-item"><a href="{% url 'board_topics' board.pk %}">{{ board.name }}</a></li>
  <li class="breadcrumb-item active">New topic</li>
{% endblock %}

{% block content %}
  <form method="post">
    {% csrf_token %}
    <div class="form-group">
      <label for="id_subject">Subject</label>
      <input type="text" class="form-control" id="id_subject" name="subject">
    </div>
    <div class="form-group">
      <label for="id_message">Message</label>
      <textarea class="form-control" id="id_message" name="message" rows="5"></textarea>
    </div>
    <button type="submit" class="btn btn-success">Post</button>
  </form>
{% endblock %}

صفحۀ بالا، یک صفحۀ ساده بوده و برای قشنگ کردن آن از Bootstrap 4 استفاده می کنیم.

در برنامۀ بالا اتفاق خاصی نیفتاده و یک فرم با متد POST درون آن تعریف شده است. (فرق بین متد POST و GET مشخص است…)

اما تنها نکتۀ آن استفاده از تگ CSRF (Cross-Site Request Forgery Token) است. از این Token با این هدف استفاده می شود که از ارسال داده‌ها توسط‌ سایت‌های دیگر به سایت ما خودداری شود. وقتی دیتایی به صورت پست به سایت ارسال می شود در ابتدای توکن مربوط به آن را چک می کند و در صورت عدم وجود این توکن، یا معتبر نبودن آن، درخواست را نادیده می گیرد. برای این کار از تگ زیر استفاده می کنیم:

{% csrf_token %}

نتیجۀ این تگ، ارسال یک فیلد مخفی و با داده‌ای مشابه زیر در کنار سایر داده‌هاست:

<input type="hidden" name="csrfmiddlewaretoken" value="jG2o6aWj65YGaqzCpl0TYTg5jn6SctjzRZ9KmluifVx0IVaxlwh97YarZKs54Y32">

مورد بعدی که برای ارسال داده‌ها لازم است مربوط به تنظیم name در قسمت ورودی‌های HTML است. از Name برای کار با داده‌های ارسالی به سمت سرور استفاده می شود.

<input type="text" class="form-control" id="id_subject" name="subject"><textarea class="form-control" id="id_message" name="message" rows="5"></textarea>

برای استفاده از دیتای آن هم به شکل زیر عمل می شود:

subject = request.POST['subject']
message = request.POST['message']

پیاده سازی ویوی برای دریافت اطلاعات از طریق HTML و شروع یک تاپیک جدید به این شکل است:

from django.contrib.auth.models import User
from django.shortcuts import render, redirect, get_object_or_404
from .models import Board, Topic, Post

def new_topic(request, pk):
    board = get_object_or_404(Board, pk=pk)

    if request.method == 'POST':
        subject = request.POST['subject']
        message = request.POST['message']

        user = User.objects.first()  # TODO: get the currently logged in user

        topic = Topic.objects.create(
            subject=subject,
            board=board,
            starter=user
        )

        post = Post.objects.create(
            message=message,
            topic=topic,
            created_by=user
        )

        return redirect('board_topics', pk=board.pk)  # TODO: redirect to the created topic page

    return render(request, 'new_topic.html', {'board': board})

در این فرم، خیلی از بخش‌ها جا مانده و فقط دیتا را از فرم می گیریم و درون دیتابیس ذخیره می کنیم. یعنی هیچ نوع از اعتبارسنجی در خصوص فیلدها صورت نگرفته و حتی ممکن است کاربر فرم خالی را ارسال کند یا تعداد کاراکترهای subject می‌تواند بیشتر از ۲۵۵ کاراکتر باشد.

در خصوص کاربرسنجی هم نیاز به کد زنی طولانی وجود دارد که در بخش دیگر توضیح می دهم اما مشخص کردن کاربری که در سیستم لاگین کرده کار آسانی است که در این جا انجام شده است.

بحش بعدی به این مربوط می شود که با کلیک روی نام بورد، به صفحۀ مربوط برویم و تمام تاپیک‌های مربوط به آن بورد را نشان دهد.

فرم با کلیک روی دکمۀ Post ارسال می شود.

همانطور که در شکل معلوم است با کلیک روی دکمۀ Post داده به درستی ارسال می شود اما بعد از آن چیزی مشاهده نمی شود و لازم است کاری کنیم که بعد از کلیک روی آن دکمه لیستی از تاپیک‌های قبلی به همراه تاپیک جدید نشان داده‌شود. این کار با ویرایش فایل topics.html صورت می پذیرد.

templates/topics.html

{% extends 'base.html' %}

{% block title %}
  {{ board.name }} - {{ block.super }}
{% endblock %}

{% block breadcrumb %}
  <li class="breadcrumb-item"><a href="{% url 'home' %}">Boards</a></li>
  <li class="breadcrumb-item active">{{ board.name }}</li>
{% endblock %}

{% block content %}
  <table class="table">
    <thead class="thead-inverse">
      <tr>
        <th>Topic</th>
        <th>Starter</th>
        <th>Replies</th>
        <th>Views</th>
        <th>Last Update</th>
      </tr>
    </thead>
    <tbody>
      {% for topic in board.topics.all %}
        <tr>
          <td>{{ topic.subject }}</td>
          <td>{{ topic.starter.username }}</td>
          <td>0</td>
          <td>0</td>
          <td>{{ topic.last_updated }}</td>
        </tr>
      {% endfor %}
    </tbody>
  </table>
{% endblock %}

خوب! این هم از این!

دو مفهوم جدید در این قطعه کد (ساخت فرم) مطرح است. در اینجا برای اولین بار از ویژگیِ topics در مدلِ Board استفاده شده. این ویژگی به صورت خودکار توسط جنگو و با استفاده از روابط معکوس (reverse relationship) تولید می شود.

def new_topic(request, pk):
    board = get_object_or_404(Board, pk=pk)

    # ...

    topic = Topic.objects.create(
        subject=subject,
        board=board,
        starter=user
    )

وقتی می گوییم board=board یعنی اینکه در مدل topic فیلد بورد را تنظیم کرده ایم و این کار از طریق ForeignKey(Board) انجام می شود.. با این کار، بوردِ ما متوجه می شود که صاحب یک تاپیک جدید شده و آن را به خود مرتبط می کند.

همینطور به این دلیل از board.topics.all به جای board.topics استفاده کردیم چون board.topics یک Related Manager است و خیلی شبیه Model Manager بوده و مثلاً با استفاده از board.objects قابل دسترسی است. بنابراین برای لیست کردن تمام تاپیک‌های یک بورد باید از متد ()board.topics.all استفاده شود. برای فیلتر کردن خروجی هم می توان از متد زیر استفاده کرد.

board.topics.filter(subject__contains='Hello')

نکتۀ خیلی مهم دیگر این است که وقتی در حال کدزنی در داخل کدهای پایتون هستیم باید برای استفاده از متدها از پرانتز استفاده کنیم. یعنی ()topics.all. اما وقتی در حال کدزنی در محیط تمپلیت‌های هستیم نیازی به پرانتز نیست. یعنی board.topics.all.

مورد بعدی استفاده از ForeignKey برای دسترسی به لیست کاربران است.

{{ topic.starter.username }}

همانطور که قبلاً هم اشاره رفت، فیلد starter مربوط به کاربرِ ایجادکنندۀ تاپیک هاست و بنابراین می توان از طریق آن به سایر ویژگی‌ها و فیلدهای یک کاربر دسترسی پیدا کرد. مثل آدرس ایمیل کاربر… برای اینکار از نقطه یا dot استفاده می کنیم؛ مثلِ topic.starter.email

تا به اینجای کار فایل topics.html را ویرایش کردیم و حالا نوبت آن است که دکمۀ new topic را به صفحه اضافه کنیم.

templates/topics.html

{% block content %}
  <div class="mb-4">
    <a href="{% url 'new_topic' board.pk %}" class="btn btn-primary">New topic</a>
  </div>

  <table class="table">
    <!-- code suppressed for brevity -->
  </table>
{% endblock %}

و مثل همیشه نوبت نوشتن تست است:

boards/tests.py

class BoardTopicsTests(TestCase):
    # ...

    def test_board_topics_view_contains_navigation_links(self):
        board_topics_url = reverse('board_topics', kwargs={'pk': 1})
        homepage_url = reverse('home')
        new_topic_url = reverse('new_topic', kwargs={'pk': 1})

        response = self.client.get(board_topics_url)

        self.assertContains(response, 'href="{0}"'.format(homepage_url))
        self.assertContains(response, 'href="{0}"'.format(new_topic_url))

تابع test_board_topics_view_contains_link_back_to_homepage  تغییر نام داده و یک assertContains هم برای چک لینکِ مربوط به «new_topic» هم به برنامه اضافه شده است.

یک تست دیگر برای ویوی فرم می نویسیم.

boards/tests.py

''' new imports below '''
from django.contrib.auth.models import User
from .views import new_topic
from .models import Board, Topic, Post

class NewTopicTests(TestCase):
    def setUp(self):
        Board.objects.create(name='Django', description='Django board.')
        User.objects.create_user(username='john', email='john@doe.com', password='123')  # <- included this line here

    # ...

    def test_csrf(self):
        url = reverse('new_topic', kwargs={'pk': 1})
        response = self.client.get(url)
        self.assertContains(response, 'csrfmiddlewaretoken')

    def test_new_topic_valid_post_data(self):
        url = reverse('new_topic', kwargs={'pk': 1})
        data = {
            'subject': 'Test title',
            'message': 'Lorem ipsum dolor sit amet'
        }
        response = self.client.post(url, data)
        self.assertTrue(Topic.objects.exists())
        self.assertTrue(Post.objects.exists())

    def test_new_topic_invalid_post_data(self):
        '''
        Invalid post data should not redirect
        The expected behavior is to show the form again with validation errors
        '''
        url = reverse('new_topic', kwargs={'pk': 1})
        response = self.client.post(url, {})
        self.assertEquals(response.status_code, 200)

    def test_new_topic_invalid_post_data_empty_fields(self):
        '''
        Invalid post data should not redirect
        The expected behavior is to show the form again with validation errors
        '''
        url = reverse('new_topic', kwargs={'pk': 1})
        data = {
            'subject': '',
            'message': ''
        }
        response = self.client.post(url, data)
        self.assertEquals(response.status_code, 200)
        self.assertFalse(Topic.objects.exists())
        self.assertFalse(Post.objects.exists())

فایل test.py رفته‌رفته بزرگ و بزرگ‌تر می شود و باید آن را هم تکه تکه کنیم و به چند فایل تقسیم کنیم اما فعلاً با همین شیوه ادامه می دهیم.

  • setUp: برای ساخت یک کاربر به صورت موقت و استفاده از آن در برنامۀ تست از objects.create_user استفاده می کنیم.
  • test_csrf: مطمئن می شویم که HTML شامل این توکن می شود.
  • test_new_topic_valid_post_data: یک دیتای معتبر ایجاد می کند و مطمئن می شود که ویوی مربوط به تاپیک و پست درست ایجاد شود.
  • test_new_topic_invalid_post_data: یک دیکشنری خالی به برنامه ارسال می کنیم و رفتار برنامه را چک می کنیم.
  • test_new_topic_invalid_post_data_empty_fields: این قسمت مانند بخش قبلی است با این تفاوت که یک سری داده را به سرور ارسال می کنیم اما بخش subject را خالی می فرستیم و واکنش برنامه را می بینیم.

تست می کنیم!

python manage.py test

و این هم خروجی:

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
........EF.....
========================================================
ERROR: test_new_topic_invalid_post_data (boards.tests.NewTopicTests)
--------------------------------------------------------
Traceback (most recent call last):
...
django.utils.datastructures.MultiValueDictKeyError: "'subject'"

========================================================
FAIL: test_new_topic_invalid_post_data_empty_fields (boards.tests.NewTopicTests)
--------------------------------------------------------
Traceback (most recent call last):
  File "/Users/vitorfs/Development/myproject/django-beginners-guide/boards/tests.py", line 115, in test_new_topic_invalid_post_data_empty_fields
    self.assertEquals(response.status_code, 200)
AssertionError: 302 != 200

--------------------------------------------------------
Ran 15 tests in 0.512s

FAILED (failures=1, errors=1)
Destroying test database for alias 'default'...

در این تست یک خطای اجرا داریم و آن هم مربوط به معتبرنبودنِ ورودی کاربرهاست که به صورت تعمدی ایجاد شده است. به جای اینکه بخواهیم این مشکل را برطرف کنیم وارد بحث Django Forms API می‌شویم.

ترجمۀ اختصاصی توسط تمدن

 

مطلب بعدی:پیاده‌سازی فرم ها به شیوۀ استاندارد

مطلب قبلی: قالب‌هایی با قابلیت استفادۀ مجدد یا Reusable Templates

0 پاسخ

دیدگاه خود را ثبت کنید

تمایل دارید در گفتگو شرکت کنید؟
نظری بدهید!

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *