فصل ۵: فرم‌ها و اعتبارسنجی — ورودی امن و فارسی

اعتبارسنجی سفارشی و فرم فارسی: ارقام فارسی، موبایل و کد ملی

کاربر ایرانی «۰۹۱۲» تایپ می‌کند

بیشتر کاربران گوشی با کیبورد فارسی عدد وارد می‌کنند. رقم «۵» فارسی (U+06F5) با «5» لاتین فرق دارد و RegexValidator با \d در پایتون آن را قبول می‌کند اما پایگاه داده آن را همان‌طور ذخیره می‌کند؛ نتیجه: دو کاربر با یک موبایل، جست‌وجوهایی که پیدا نمی‌کنند و پیامکی که نمی‌رسد. راه درست این است که ورودی را قبل از هر اعتبارسنجی نرمال کنیم؛ یعنی در to_python یک فیلد فرم اختصاصی.

# apps/core/forms_fa.py
import re

from django import forms
from django.core.exceptions import ValidationError

DIGITS = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789")
ARABIC_LETTERS = str.maketrans({"ي": "ی", "ك": "ک", "ى": "ی"})


def normalize_digits(value):
    return (value or "").translate(DIGITS).strip()


def normalize_text(value):
    return re.sub(r"\s+", " ", (value or "").translate(ARABIC_LETTERS)).strip()


class MobileField(forms.CharField):
    default_error_messages = {"invalid": "شماره‌ی موبایل معتبر نیست؛ مثال: 09121234567"}

    def to_python(self, value):
        value = re.sub(r"[\s\-()]", "", normalize_digits(super().to_python(value)))
        if value.startswith("+98"):
            value = "0" + value[3:]
        elif value.startswith("0098"):
            value = "0" + value[4:]
        elif value.startswith("9") and len(value) == 10:
            value = "0" + value
        return value

    def validate(self, value):
        super().validate(value)
        if value and not re.fullmatch(r"09\d{9}", value):
            raise ValidationError(self.error_messages["invalid"], code="invalid")


def validate_national_code(value):
    code = normalize_digits(value)
    if not re.fullmatch(r"\d{10}", code) or len(set(code)) == 1:
        raise ValidationError("کد ملی باید ۱۰ رقم معتبر باشد.", code="invalid")
    check = int(code[9])
    s = sum(int(code[i]) * (10 - i) for i in range(9)) % 11
    if (s < 2 and check != s) or (s >= 2 and check != 11 - s):
        raise ValidationError("کد ملی نامعتبر است.", code="checksum")

استفاده در ModelForm با clean_ و clean

class CustomerForm(forms.ModelForm):
    mobile = MobileField(label="موبایل")
    national_code = forms.CharField(label="کد ملی", required=False)

    class Meta:
        model = Customer
        fields = ["full_name", "mobile", "national_code", "city"]

    def clean_full_name(self):
        return normalize_text(self.cleaned_data["full_name"])

    def clean_national_code(self):
        code = normalize_digits(self.cleaned_data.get("national_code"))
        if code:
            validate_national_code(code)
        return code

    def clean(self):
        cleaned = super().clean()
        if cleaned.get("city") == "خارج از کشور" and not cleaned.get("national_code"):
            self.add_error("national_code", "برای ارسال خارجی کد ملی الزامی است.")
        return cleaned

الگوریتم کد ملی: ۹ رقم اول را به‌ترتیب در ۱۰ تا ۲ ضرب و جمع کنید، باقیمانده بر ۱۱ را حساب کنید؛ اگر کمتر از ۲ بود رقم کنترل باید خودش باشد، وگرنه ۱۱ منهای آن. کدهایی مثل «۱۱۱۱۱۱۱۱۱۱» از نظر ریاضی درست‌اند اما نامعتبرند؛ برای همین len(set(code)) == 1 را رد می‌کنیم.

نکته‌هایی که کمتر کسی می‌داند

  • اگر نرمال‌سازی را در clean_mobile بگذارید دیر است: max_length=11 قبل از آن اجرا شده و «+989121234567» را با خطای طول رد کرده است. جای نرمال‌سازی to_python است.
  • \d در regex پایتون ارقام فارسی و عربی را هم تطبیق می‌دهد (یونیکد)؛ اگر فقط لاتین می‌خواهید [0-9] بنویسید یا پرچم re.ASCII بدهید.
  • clean() حتی وقتی فیلدها خطا دارند اجرا می‌شود؛ پس همیشه با cleaned.get() بخوانید نه cleaned[...]، وگرنه KeyError می‌گیرید.
  • add_error(None, "...") خطای کلی فرم (non_field_errors) می‌سازد و در قالب با {{ form.non_field_errors }} نمایش داده می‌شود.
  • نرمال‌سازی «ي» و «ك» عربی را هم در ذخیره و هم در عبارت جست‌وجو انجام دهید؛ اگر فقط یکی را درست کنید، جست‌وجو همچنان رکوردهای قدیمی را پیدا نمی‌کند.

برای ذخیره‌ی پیشرفت و شرکت در آزمون، وارد شوید — رایگان است.