[Django] 日本ひげ男協会のサイトを作成する。その3

注意

この記事は、id:SumiTomohiko:20070125:1169742683の続きです。

環境

この記事の内容は、Ubuntu 6.10, Python 2.4, Django 0.95で確認しました。

Userクラスにパスワードを追加する。

前回までのUserクラスにはパスワードがありませんでした。これではログインできません。というわけで、Userクラスにパスワードを追加するのですが、これが一仕事となります。なぜならば、パスワードを入力させるときは2回入力させて誤入力を防がなければなりませんが、このように画面の項目とモデルが一致しない場合、前回まで使用していた汎用ビューが使えません。ビューは、自前で実装することになります。また今回は、マニピュレータを使用します。マニピュレータは、フォームを表します。マニピュレータはフォームの項目を持ち、入力値の検証を行います。

会員登録ビュー

ビュー

まず、汎用ビューのソースを読んで、どのようにマニピュレータを使ってリクエストを処理させるか調べます。参考にする汎用ビューは、django.views.generic.create_update.create_objectです。ソースコードで言うと、/usr/lib/python2.4/site-packages/Django-0.95-py2.4.egg/django/views/generic/create_update.pyのcreate_object関数です。以下のようになっています。

def create_object(request, model, template_name=None,
        template_loader=loader, extra_context=None, post_save_redirect=None,
        login_required=False, follow=None, context_processors=None):
    """
    Generic object-creation function.

    Templates: ``<app_label>/<model_name>_form.html``
    Context:
        form
            the form wrapper for the object
    """
    if extra_context is None: extra_context = {}
    if login_required and not request.user.is_authenticated():
        return redirect_to_login(request.path)

    manipulator = model.AddManipulator(follow=follow)
    if request.POST:
        # If data was POSTed, we're trying to create a new object
        new_data = request.POST.copy()

        if model._meta.has_field_type(FileField):
            new_data.update(request.FILES)

        # Check for errors
        errors = manipulator.get_validation_errors(new_data)
        manipulator.do_html2python(new_data)

        if not errors:
            # No errors -- this means we can save the data!
            new_object = manipulator.save(new_data)

            if request.user.is_authenticated():
                request.user.message_set.create(message="The %s was created successfully." % model._meta.verbose_name)

            # Redirect to the new object: first by trying post_save_redirect,
            # then by obj.get_absolute_url; fail if neither works.
            if post_save_redirect:
                return HttpResponseRedirect(post_save_redirect % new_object.__dict__)
            elif hasattr(new_object, 'get_absolute_url'):
                return HttpResponseRedirect(new_object.get_absolute_url())
            else:
                raise ImproperlyConfigured("No URL to redirect to from generic create view.")
    else:
        # No POST, so we want a brand new form without any data or errors
        errors = {}
        new_data = manipulator.flatten_data()

    # Create the FormWrapper, template, context, response
    form = forms.FormWrapper(manipulator, new_data, errors)
    if not template_name:
        template_name = "%s/%s_form.html" % (model._meta.app_label, model._meta.object_name.lower())
    t = template_loader.get_template(template_name)
    c = RequestContext(request, {
        'form': form,
    }, context_processors)
    for key, value in extra_context.items():
        if callable(value):
            c[key] = value()
        else:
            c[key] = value
    return HttpResponse(t.render(c))

要約すれば、以下のようになります。

リクエストがPOSTだったら:
    POSTされたデータから辞書を作成する。
    辞書の値を検証する。
    エラーがなければ:
        オブジェクトをデータベースに保存する。
        リダイレクトする。
そうでなければ:
    新しい辞書を作成する。

辞書の内容を表示するフォームを、マニピュレータから作成する。
画面を出力する。

新しいビューも、この流れに沿って作成します。

会員を登録するビューは、juma.user.views.add関数とします。これをuser/urls.pyに登録します。

from django.conf.urls.defaults import *
from juma.user.models import User

urlpatterns = patterns('',
    (r'^add/$', 'juma.user.views.add'), 
    (r'^list/$', 'django.views.generic.list_detail.object_list', { 'queryset': User.objects.all(), 'allow_empty': True, 'paginate_by': 20 }), 
)

# vim: tabstop=4 shiftwidth=4 expandtab

次にjuma.user.views.add関数を作成します。最終的に、user/views.pyは以下のようになりました。

from django import forms
from django.http import HttpResponseRedirect
from django.shortcuts import render_to_response
from juma.user.models import User
import user.manipulators

def add(request):
    manipulator = user.manipulators.UserManipulator()
    if request.POST:
        new_data = request.POST.copy()
        new_data.update(request.FILES)
        errors = manipulator.get_validation_errors(new_data)
        if not errors:
            manipulator.do_html2python(new_data)

            u = User(name=new_data["name"], password=new_data["password"], from_year=new_data["from_year"], introduction=new_data["introduction"], web=new_data["web"], blog=new_data["blog"])
            image_file = new_data["image_file"]
            if image_file:
                filename = image_file["filename"]
                u.image = filename
                u.save_image_file(filename, image_file["content"])
            u.save()

            return HttpResponseRedirect("/user/list/")
    else:
        errors = new_data = {}

    form = forms.FormWrapper(manipulator, new_data, errors)
    return render_to_response('user/user_form.html', {"form": form, "title": "会員登録"})

# vim: tabstop=4 shiftwidth=4 expandtab

細かいところは違いますが、大まかな流れは汎用ビューと同じになっています。この関数の中で注意を要するのは、画像(image_fileフィールド)を保存するところです。Userクラスにはimageフィールドがありますが、Userクラスのインスタンスuに対して、

u.image = new_data["image_file"]

としても、エラーとなります。new_data["image_file"]は、filenameとcontentという2つのキーからなる辞書です。まず、Userクラスのimageフィールドには、ファイル名を保存します。

u.image = new_data["image_file"]["filename"]

次にファイル本体を保存しますが、ImageFieldクラスのインスタンスであるimageフィールドには、save_<フィールド名>_fileメソッドがあります(今回の場合、save_image_fileメソッドです)。このメソッドは、/usr/lib/python2.4/site-packages/Django-0.95-py2.4.egg/django/db/models/fields/__init__.pyのFileFieldクラスで動的に作成されています。

:
class FileField(Field):
:
    def contribute_to_class(self, cls, name):
        super(FileField, self).contribute_to_class(cls, name)
        setattr(cls, 'get_%s_filename' % self.name, curry(cls._get_FIELD_filename, field=self))
        setattr(cls, 'get_%s_url' % self.name, curry(cls._get_FIELD_url, field=self))
        setattr(cls, 'get_%s_size' % self.name, curry(cls._get_FIELD_size, field=self))
        setattr(cls, 'save_%s_file' % self.name, lambda instance, filename, raw_contents: instance._save_FIELD_file(self, filename, raw_contents))
        dispatcher.connect(self.delete_file, signal=signals.post_delete, sender=cls)
:

ついでに深追いすると、_save_FIELD_fileメソッドは/usr/lib/python2.4/site-packages/Django-0.95-py2.4.egg/django/db/models/base.pyのModelクラスにあり、以下のようになっています。

:
class Model(object):
:
    def _save_FIELD_file(self, field, filename, raw_contents):
        directory = field.get_directory_name()
        try: # Create the date-based directory if it doesn't exist.
            os.makedirs(os.path.join(settings.MEDIA_ROOT, directory))
        except OSError: # Directory probably already exists.
            pass
        filename = field.get_filename(filename)

        # If the filename already exists, keep adding an underscore to the name of
        # the file until the filename doesn't exist.
        while os.path.exists(os.path.join(settings.MEDIA_ROOT, filename)):
            try:
                dot_index = filename.rindex('.')
            except ValueError: # filename has no dot
                filename += '_'
            else:
                filename = filename[:dot_index] + '_' + filename[dot_index:]

        # Write the file to disk.
        setattr(self, field.attname, filename)

        full_filename = self._get_FIELD_filename(field)
        fp = open(full_filename, 'wb')
        fp.write(raw_contents)
        fp.close()

        # Save the width and/or height, if applicable.
        if isinstance(field, ImageField) and (field.width_field or field.height_field):
            from django.utils.images import get_image_dimensions
            width, height = get_image_dimensions(full_filename)
            if field.width_field:
                setattr(self, field.width_field, width)
            if field.height_field:
                setattr(self, field.height_field, height)

        # Save the object, because it has changed.
        self.save()

    _save_FIELD_file.alters_data = True
:

中を読んで分かる通り、設定からMEDIA_ROOTを読んで、その下にファイルを保存しています。また、重複するファイル名がある場合は、ファイルのベース名の後ろにアンダースコアをつける、ということも分かります。

さて、save_image_fileメソッドを使うと、ファイルの保存は以下のようになります。

u.save_image_file(new_data["image_file"]["filename"], new_data["image_file"]["content"])

なお、Userクラスのimageフィールドは必須項目ではありません。よって、new_data["image_file"]の値から、ファイルがアップロードされているか検査しています。

マニピュレータ

続いて、マニピュレータを作成します。マニピュレータは、user/manipulators.pyに記述することにしました。最終的に、以下のようになりました。

from django import forms
from django.core import validators
import user.validators

from_year_validator = user.validators.FromYearValidator()
password_length_validator = user.validators.PasswordLengthValidator()
password_validator = validators.AlwaysMatchesOtherField("password2", "2つのパスワードが異なります。")

class UserManipulator(forms.Manipulator):
    def __init__(self):
        self.fields = (
            forms.TextField(field_name="name", length=16, maxlength=32, is_required=True), 
            forms.PasswordField(field_name="password", length=16, maxlength=32, is_required=True, validator_list=[password_length_validator, password_validator]), 
            forms.PasswordField(field_name="password2", length=16, maxlength=32, is_required=True, validator_list=[password_length_validator]), 
            forms.PositiveSmallIntegerField(field_name="from_year", maxlength=4, is_required=True, validator_list=[from_year_validator]), 
            forms.ImageUploadField(field_name="image_file"), 
            forms.LargeTextField(field_name="introduction", rows=5, maxlength=8192), 
            forms.URLField(field_name="web", maxlength=256), 
            forms.URLField(field_name="blog", maxlength=256),
        )

# vim: tabstop=4 shiftwidth=4 expandtab

コンストラクタで、fieldsメンバにフォームの項目のリストを設定しています。Djangoには様々なフィールドが用意されており、フィールドごとに設定できる項目も異なります。そこで、ソースコードgrepして、用意されているフィールドとそのコンストラクタを一覧にしてみました。参考にしてください(空白行は適宜追加しました)。

$ cd /usr/lib/python2.4/site-packages/Django-0.95-py2.4.egg/django
$ grep 'class\|def __init__' forms/__init__.py
class EmptyValue(Exception):

class Manipulator(object):
    def __init__(self):

class FormWrapper(object):
    def __init__(self, manipulator, data, error_dict, edit_inline=True):

class FormFieldWrapper(object):
    def __init__(self, formfield, data, error_list):

class FormFieldCollection(FormFieldWrapper):
    def __init__(self, formfield_dict):

class InlineObjectCollection(object):
    def __init__(self, parent_manipulator, rel_obj, data, errors):

class FormField(object):

class TextField(FormField):
    def __init__(self, field_name, length=30, maxlength=None, is_required=False, validator_list=None, member_name=None):

class PasswordField(TextField):

class LargeTextField(TextField):
    def __init__(self, field_name, rows=10, cols=40, is_required=False, validator_list=None, maxlength=None):

class HiddenField(FormField):
    def __init__(self, field_name, is_required=False, validator_list=None):

class CheckboxField(FormField):
    def __init__(self, field_name, checked_by_default=False, validator_list=None):

class SelectField(FormField):
    def __init__(self, field_name, choices=None, size=1, is_required=False, validator_list=None, member_name=None):

class NullSelectField(SelectField):

class RadioSelectField(FormField):
    def __init__(self, field_name, choices=None, ul_class='', is_required=False, validator_list=None, member_name=None):

class NullBooleanField(SelectField):
    def __init__(self, field_name, is_required=False, validator_list=None):

class SelectMultipleField(SelectField):

class CheckboxSelectMultipleField(SelectMultipleField):
    def __init__(self, field_name, choices=None, ul_class='', validator_list=None):

class FileUploadField(FormField):
    def __init__(self, field_name, is_required=False, validator_list=None):

class ImageUploadField(FileUploadField):
    def __init__(self, *args, **kwargs):

class IntegerField(TextField):
    def __init__(self, field_name, length=10, maxlength=None, is_required=False, validator_list=None, member_name=None):

class SmallIntegerField(IntegerField):
    def __init__(self, field_name, length=5, maxlength=5, is_required=False, validator_list=None):

class PositiveIntegerField(IntegerField):
    def __init__(self, field_name, length=10, maxlength=None, is_required=False, validator_list=None):

class PositiveSmallIntegerField(IntegerField):
    def __init__(self, field_name, length=5, maxlength=None, is_required=False, validator_list=None):

class FloatField(TextField):
    def __init__(self, field_name, max_digits, decimal_places, is_required=False, validator_list=None):

class DatetimeField(TextField):
    def __init__(self, field_name, length=30, maxlength=None, is_required=False, validator_list=None):

class DateField(TextField):
    def __init__(self, field_name, is_required=False, validator_list=None):

class TimeField(TextField):
    def __init__(self, field_name, is_required=False, validator_list=None):

class EmailField(TextField):
    def __init__(self, field_name, length=50, maxlength=75, is_required=False, validator_list=None):

class URLField(TextField):
    def __init__(self, field_name, length=50, maxlength=200, is_required=False, validator_list=None):

class IPAddressField(TextField):
    def __init__(self, field_name, length=15, maxlength=15, is_required=False, validator_list=None):

class FilePathField(SelectField):
    def __init__(self, field_name, path, match=None, recursive=False, is_required=False, validator_list=None):

class PhoneNumberField(TextField):
    def __init__(self, field_name, is_required=False, validator_list=None):

class USStateField(TextField):
    def __init__(self, field_name, is_required=False, validator_list=None):

class CommaSeparatedIntegerField(TextField):
    def __init__(self, field_name, maxlength=None, is_required=False, validator_list=None):

class RawIdAdminField(CommaSeparatedIntegerField):

class XMLLargeTextField(LargeTextField):
    def __init__(self, field_name, schema_path, **kwargs):
ヴァリデータ

さて、フォームのフィールドには、ヴァリデータを設定する必要があります。Djangoで十分な種類のヴァリデータが用意されていればいいのですが、残念ながらそういうわけではないので、ないものは作成します。今回は、ひげをはやし始めた年が来年以降ではないことを検査するヴァリデータFromYearValidatorと、パスワードが6文字以上であることを検査するヴァリデータPasswordLengthValidatorが必要になったので、作成しました。これらは、user/validators.pyに記述することにしました。

from datetime import datetime
from django.core.validators import ValidationError

class FromYearValidator(object):
    def __init__(self, message="未来の年です。"):
        self._message = message

    def __call__(self, field_data, all_data):
        if datetime.now().year < int(field_data):
            raise ValidationError, self._message

class PasswordLengthValidator(object):
    def __init__(self, minlength=6, message="%(minlength)d文字以上が必要です。"):
        self._minlength = minlength
        self._message = message

    def __call__(self, field_data, all_data):
        if len(field_data) < self._minlength:
            raise ValidationError, self._message % dict(minlength=self._minlength)

# vim: tabstop=4 shiftwidth=4 expandtab

ヴァリデータは、エラーのときにValidationErrorを発生させるようにします。また、ヴァリデータで検査される段階では、フォームの値がまだ文字列型であることに、注意が必要です(検査が終了してから、数値なりに変換されます)。

これで、会員登録画面ができました。

エラーメッセージの日本語化

エラーメッセージが英語の場合は、settings.pyに、以下を記述してください。

LANGUAGE_CODE = 'ja'