nilva-django-utils 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,46 @@
1
+ """Nilva Django Utils — the Nilva house Iranian-locale toolkit.
2
+
3
+ Persian / Arabic-Indic digit normalization, Jalali calendar formatting and
4
+ admin datepickers, national-code (کد ملی) validation, Iranian phone-number
5
+ normalization, bank identifier checksums (IBAN, card number) and the shared
6
+ regex patterns that were previously copy-pasted across the Nilva backends.
7
+
8
+ The core (``digits``, ``numbers``, ``national_id``, ``phone``, ``bank``,
9
+ ``patterns``, ``conf``) is pure standard library and works in any Python
10
+ service. The optional layers are imported explicitly and never load from
11
+ here:
12
+
13
+ * ``nilva_django_utils.jalali`` — requires ``jdatetime`` (``[jalali]`` extra)
14
+ * ``nilva_django_utils.validators`` — requires Django (``[django]`` extra)
15
+ * ``nilva_django_utils.admin`` — requires Django + ``django-jalali``
16
+ (``[jalali-admin]`` extra)
17
+ * ``phone.normalize_phone`` — requires ``phonenumbers``
18
+ (``[phonenumbers]`` extra)
19
+ """
20
+
21
+ __version__ = "0.1.0"
22
+
23
+ from . import patterns
24
+ from .bank import is_valid_card_number, is_valid_iban
25
+ from .digits import to_english_digits, to_persian_digits, translate_numbers
26
+ from .national_id import is_valid_national_id, normalize_national_id
27
+ from .numbers import convert_to_seconds, separate_thousands, truncate
28
+ from .phone import is_valid_iranian_mobile, normalize_iranian_mobile, to_e164_iranian
29
+
30
+ __all__ = [
31
+ "__version__",
32
+ "patterns",
33
+ "is_valid_card_number",
34
+ "is_valid_iban",
35
+ "to_english_digits",
36
+ "to_persian_digits",
37
+ "translate_numbers",
38
+ "is_valid_national_id",
39
+ "normalize_national_id",
40
+ "convert_to_seconds",
41
+ "separate_thousands",
42
+ "truncate",
43
+ "is_valid_iranian_mobile",
44
+ "normalize_iranian_mobile",
45
+ "to_e164_iranian",
46
+ ]
@@ -0,0 +1,81 @@
1
+ """Jalali datepickers and display helpers for the Django admin
2
+ (the ``[jalali-admin]`` extra: Django + ``django-jalali``).
3
+
4
+ Ported from the Depository backend: dates stay *gregorian* in the database
5
+ (the API contract is gregorian ISO-8601) while the admin renders and accepts
6
+ Jalali. ``JalaliDateAdminMixin`` wires the widget onto every ``DateField`` of
7
+ a ``ModelAdmin``; ``jalali_display`` / ``jalali_datetime_display`` are for
8
+ read-only ``list_display`` / ``readonly_fields`` cells.
9
+
10
+ Projects that instead store ``jmodels.jDateField`` natively (Shahab) or use
11
+ ``django-jalali-date`` mixins don't need this module.
12
+ """
13
+
14
+ import datetime
15
+
16
+ import jdatetime
17
+ from django import forms
18
+ from django.db import models
19
+ from django.utils.safestring import mark_safe
20
+ from django_jalali.admin.widgets import AdminjDateWidget
21
+
22
+ from .jalali import JALALI_DATE_FORMAT, JALALI_DATETIME_FORMAT, parse_jalali, to_jalali, to_jalali_datetime
23
+
24
+
25
+ def jalali_display(value, fmt: str = JALALI_DATE_FORMAT) -> str:
26
+ """Read-only admin cell: Jalali date wrapped LTR so RTL renders cleanly."""
27
+ text = to_jalali(value, fmt)
28
+ if not text:
29
+ return "-"
30
+ return mark_safe(f'<span dir="ltr">{text}</span>')
31
+
32
+
33
+ def jalali_datetime_display(value, fmt: str = JALALI_DATETIME_FORMAT) -> str:
34
+ """Read-only admin cell: Jalali date+time wrapped LTR."""
35
+ text = to_jalali_datetime(value, fmt)
36
+ if not text:
37
+ return "-"
38
+ return mark_safe(f'<span dir="ltr">{text}</span>')
39
+
40
+
41
+ class JalaliAdminDateWidget(AdminjDateWidget):
42
+ """Persian datepicker over a *gregorian* ``DateField``.
43
+
44
+ Inherits the django-jalali admin datepicker media (jQuery UI calendar) and
45
+ the ``vjDateField`` class the JS binds to, but converts the incoming
46
+ gregorian value to a Jalali display string.
47
+ """
48
+
49
+ def __init__(self, attrs=None, format=None):
50
+ super().__init__(attrs=attrs, format=format or JALALI_DATE_FORMAT)
51
+
52
+ def _format_value(self, value):
53
+ if isinstance(value, datetime.datetime):
54
+ value = value.date()
55
+ if isinstance(value, datetime.date) and not isinstance(value, jdatetime.date):
56
+ value = jdatetime.date.fromgregorian(date=value)
57
+ return super()._format_value(value)
58
+
59
+
60
+ class JalaliFormDateField(forms.DateField):
61
+ """Form field that reads a Jalali string and stores a gregorian date."""
62
+
63
+ widget = JalaliAdminDateWidget
64
+
65
+ def to_python(self, value):
66
+ if value in self.empty_values:
67
+ return None
68
+ if isinstance(value, str):
69
+ try:
70
+ return parse_jalali(value)
71
+ except ValueError:
72
+ pass
73
+ return super().to_python(value)
74
+
75
+
76
+ class JalaliDateAdminMixin:
77
+ """Mix into a ModelAdmin to give its ``DateField``\\ s a Persian datepicker."""
78
+
79
+ formfield_overrides = {
80
+ models.DateField: {"form_class": JalaliFormDateField, "widget": JalaliAdminDateWidget},
81
+ }
@@ -0,0 +1,43 @@
1
+ """Bank identifier checksums.
2
+
3
+ The backends only regex-check IBANs (``^IR\\d{24}$``) and card numbers
4
+ (``^\\d{16}$``); these helpers add the real checksums (ISO 13616 mod-97 and
5
+ Luhn) on top. Input digits may be Persian or Arabic-Indic.
6
+ """
7
+
8
+ import re
9
+
10
+ from .digits import to_english_digits
11
+
12
+ _IBAN_STRUCTURE_RE = re.compile(r"^[A-Z]{2}\d{2}[A-Z0-9]{1,30}$")
13
+
14
+ _STRIP_RE = re.compile(r"[\s\-]")
15
+
16
+
17
+ def is_valid_iban(value) -> bool:
18
+ """ISO 13616 mod-97 IBAN check. Iranian IBANs (شبا) are ``IR`` + 24
19
+ digits; other countries validate too. Accepts spaces/dashes and a
20
+ leading ``شبا``-less bare form is NOT accepted — the ``IR`` prefix is
21
+ required."""
22
+ iban = _STRIP_RE.sub("", to_english_digits(value).strip()).upper()
23
+ if not _IBAN_STRUCTURE_RE.match(iban):
24
+ return False
25
+ rearranged = iban[4:] + iban[:4]
26
+ numeric = "".join(str(int(char, 36)) for char in rearranged)
27
+ return int(numeric) % 97 == 1
28
+
29
+
30
+ def is_valid_card_number(value) -> bool:
31
+ """16-digit bank card Luhn check (Iranian cards are Luhn-valid)."""
32
+ card = _STRIP_RE.sub("", to_english_digits(value).strip())
33
+ if len(card) != 16 or not card.isdigit() or card == card[0] * 16:
34
+ return False
35
+ total = 0
36
+ for index, char in enumerate(card):
37
+ digit = int(char)
38
+ if index % 2 == 0:
39
+ digit *= 2
40
+ if digit > 9:
41
+ digit -= 9
42
+ total += digit
43
+ return total % 10 == 0
@@ -0,0 +1,27 @@
1
+ """Settings snippets (pure data — safe to import from a settings module).
2
+
3
+ ``JALALI_SETTINGS`` registers the admin datepicker JS/CSS that
4
+ ``django-jalali`` ships, exactly as the Shahab backend does::
5
+
6
+ # settings.py
7
+ from nilva_django_utils.conf import JALALI_SETTINGS # noqa: F401
8
+ """
9
+
10
+ JALALI_SETTINGS = {
11
+ # JavaScript static files for the admin Jalali date widget
12
+ "ADMIN_JS_STATIC_FILES": [
13
+ "admin/jquery.ui.datepicker.jalali/scripts/jquery-1.10.2.min.js",
14
+ "admin/jquery.ui.datepicker.jalali/scripts/jquery.ui.core.js",
15
+ "admin/jquery.ui.datepicker.jalali/scripts/jquery.ui.datepicker-cc.js",
16
+ "admin/jquery.ui.datepicker.jalali/scripts/calendar.js",
17
+ "admin/jquery.ui.datepicker.jalali/scripts/jquery.ui.datepicker-cc-fa.js",
18
+ "admin/main.js",
19
+ ],
20
+ # CSS static files for the admin Jalali date widget
21
+ "ADMIN_CSS_STATIC_FILES": {
22
+ "all": [
23
+ "admin/jquery.ui.datepicker.jalali/themes/base/jquery-ui.min.css",
24
+ "admin/css/main.css",
25
+ ]
26
+ },
27
+ }
@@ -0,0 +1,44 @@
1
+ """Persian / Arabic-Indic digit normalization.
2
+
3
+ Every Nilva backend accepts user input typed with Persian (۰-۹) or
4
+ Arabic-Indic (٠-٩) digits and renders numbers back in Persian. These tables
5
+ are the union of the per-project ``str.maketrans`` maps.
6
+ """
7
+
8
+ from .numbers import separate_thousands
9
+
10
+ ASCII_DIGITS = "0123456789"
11
+ PERSIAN_DIGITS = "۰۱۲۳۴۵۶۷۸۹"
12
+ ARABIC_INDIC_DIGITS = "٠١٢٣٤٥٦٧٨٩"
13
+
14
+ #: Persian and Arabic-Indic digits -> ASCII ("۱۲۳" or "١٢٣" -> "123").
15
+ TO_ENGLISH_TABLE = str.maketrans(PERSIAN_DIGITS + ARABIC_INDIC_DIGITS, ASCII_DIGITS * 2)
16
+
17
+ #: ASCII and Arabic-Indic digits -> Persian ("123" or "١٢٣" -> "۱۲۳").
18
+ TO_PERSIAN_TABLE = str.maketrans(ASCII_DIGITS + ARABIC_INDIC_DIGITS, PERSIAN_DIGITS * 2)
19
+
20
+
21
+ def to_english_digits(text) -> str:
22
+ """Localized digits (Persian and Arabic-Indic) to ASCII. ``None`` -> ``""``."""
23
+ if text is None:
24
+ return ""
25
+ return str(text).translate(TO_ENGLISH_TABLE)
26
+
27
+
28
+ def to_persian_digits(text, separate: bool = False) -> str:
29
+ """ASCII (and Arabic-Indic) digits to Persian. ``None`` -> ``""``.
30
+
31
+ With ``separate=True`` numeric values get thousands separators first
32
+ (``1234567`` -> ``"۱,۲۳۴,۵۶۷"``).
33
+ """
34
+ if text is None:
35
+ return ""
36
+ if separate:
37
+ text = separate_thousands(text)
38
+ return str(text).translate(TO_PERSIAN_TABLE)
39
+
40
+
41
+ def translate_numbers(text, is_separate: bool = False) -> str:
42
+ """Backwards-compatible alias covering both legacy signatures
43
+ (``utils.general.number_utils.translate_numbers``)."""
44
+ return to_persian_digits(text, separate=is_separate)
@@ -0,0 +1,71 @@
1
+ """Jalali (Persian) calendar formatting and parsing.
2
+
3
+ Requires ``jdatetime`` (the ``[jalali]`` extra). Dates stay *gregorian* in
4
+ the database and over the API — these helpers only convert at the display /
5
+ input boundary, the convention shared by the Depository and Cheshmeh
6
+ backends. Django is optional: timezone-aware datetimes are localized through
7
+ ``django.utils.timezone`` only when Django is installed and configured.
8
+ """
9
+
10
+ import datetime
11
+
12
+ import jdatetime
13
+
14
+ from .digits import to_english_digits
15
+
16
+ JALALI_DATE_FORMAT = "%Y-%m-%d"
17
+ JALALI_DATETIME_FORMAT = "%Y-%m-%d %H:%M"
18
+
19
+ #: Input formats accepted when parsing a Jalali string back to gregorian.
20
+ JALALI_INPUT_FORMATS = (JALALI_DATE_FORMAT, "%Y/%m/%d")
21
+
22
+
23
+ def _localtime(value: datetime.datetime) -> datetime.datetime:
24
+ try:
25
+ from django.core.exceptions import ImproperlyConfigured
26
+ from django.utils import timezone
27
+ except ImportError:
28
+ return value
29
+ try:
30
+ return timezone.localtime(value)
31
+ except ImproperlyConfigured:
32
+ return value
33
+
34
+
35
+ def to_jalali(value, fmt: str = JALALI_DATE_FORMAT) -> str:
36
+ """Gregorian date/datetime -> Jalali string (``""`` when empty)."""
37
+ if value is None:
38
+ return ""
39
+ if isinstance(value, datetime.datetime):
40
+ value = value.date()
41
+ if isinstance(value, datetime.date) and not isinstance(value, jdatetime.date):
42
+ value = jdatetime.date.fromgregorian(date=value)
43
+ if hasattr(value, "strftime"):
44
+ return value.strftime(fmt)
45
+ return str(value)
46
+
47
+
48
+ def to_jalali_datetime(value, fmt: str = JALALI_DATETIME_FORMAT) -> str:
49
+ """Gregorian datetime -> Jalali date+time string, localized to the active
50
+ Django timezone when one is configured (``""`` when empty)."""
51
+ if value is None:
52
+ return ""
53
+ if isinstance(value, datetime.datetime):
54
+ if value.tzinfo is not None:
55
+ value = _localtime(value)
56
+ return jdatetime.datetime.fromgregorian(datetime=value).strftime(fmt)
57
+ return to_jalali(value, fmt)
58
+
59
+
60
+ def parse_jalali(value, formats: tuple = JALALI_INPUT_FORMATS) -> datetime.date:
61
+ """Jalali date string (any digit script) -> gregorian ``datetime.date``.
62
+
63
+ Raises ``ValueError`` when no format matches.
64
+ """
65
+ normalized = to_english_digits(value).strip()
66
+ for fmt in formats:
67
+ try:
68
+ return jdatetime.datetime.strptime(normalized, fmt).togregorian().date()
69
+ except ValueError:
70
+ continue
71
+ raise ValueError(f"Not a valid Jalali date: {value!r}")
@@ -0,0 +1,35 @@
1
+ """Iranian national code (کد ملی) validation.
2
+
3
+ Implements the real mod-11 weighted checksum (the algorithm behind the
4
+ ``codemelli`` package) instead of the ``isdigit()`` / 10-digit-regex checks
5
+ scattered across the backends. Input is digit-normalized first, so a code
6
+ typed as ``۱۰۰۰۰۰۰۰۰۷`` validates the same as ``1000000007``.
7
+ """
8
+
9
+ from .digits import to_english_digits
10
+
11
+
12
+ def normalize_national_id(value, pad: bool = False) -> str:
13
+ """Clean ``value`` to an ASCII digit string (localized digits translated,
14
+ spaces/dashes stripped).
15
+
16
+ ``pad=True`` zero-fills 8/9-digit codes back to 10 digits — Excel imports
17
+ routinely strip leading zeros.
18
+ """
19
+ code = to_english_digits(value).strip().replace(" ", "").replace("-", "")
20
+ if pad and code.isdigit() and 8 <= len(code) < 10:
21
+ code = code.zfill(10)
22
+ return code
23
+
24
+
25
+ def is_valid_national_id(value, pad: bool = False) -> bool:
26
+ """True when ``value`` is a structurally valid کد ملی (10 digits, not all
27
+ identical, mod-11 checksum passes)."""
28
+ code = normalize_national_id(value, pad=pad)
29
+ if len(code) != 10 or not code.isdigit():
30
+ return False
31
+ if code == code[0] * 10:
32
+ return False
33
+ check = int(code[9])
34
+ remainder = sum(int(code[i]) * (10 - i) for i in range(9)) % 11
35
+ return check == remainder if remainder < 2 else check == 11 - remainder
@@ -0,0 +1,41 @@
1
+ """Number and duration helpers shared by the Nilva backends."""
2
+
3
+ import re
4
+ from datetime import timedelta
5
+ from math import floor
6
+
7
+ UNITS = {"s": "seconds", "m": "minutes", "h": "hours", "d": "days", "w": "weeks"}
8
+
9
+ _DURATION_RE = re.compile(r"(?P<value>\d+(\.\d+)?)\s*(?P<unit>[smhdw]?)", flags=re.I)
10
+
11
+
12
+ def convert_to_seconds(time_str: str, units: str = "smhdw") -> int:
13
+ """Human-readable duration to seconds: ``"1h30m"`` -> 5400, ``"2d"`` -> 172800.
14
+
15
+ A bare number counts as seconds (``"90"`` -> 90). ``units`` restricts which
16
+ unit letters are honoured.
17
+ """
18
+ timedelta_data = {}
19
+ for match in _DURATION_RE.finditer(str(time_str)):
20
+ unit = match.group("unit").lower() or "s"
21
+ if unit in units and unit in UNITS:
22
+ timedelta_data[UNITS[unit]] = float(match.group("value"))
23
+ return int(timedelta(**timedelta_data).total_seconds())
24
+
25
+
26
+ def separate_thousands(value) -> str:
27
+ """``1234567`` -> ``"1,234,567"``. Accepts ints, floats and numeric
28
+ strings; non-numeric text comes back unchanged."""
29
+ if isinstance(value, str):
30
+ stripped = value.strip()
31
+ try:
32
+ value = int(stripped) if stripped.lstrip("+-").isdigit() else float(stripped)
33
+ except ValueError:
34
+ return value
35
+ return f"{value:,}"
36
+
37
+
38
+ def truncate(number, digits: int = 6) -> float:
39
+ """Truncate (not round) ``number`` to ``digits`` decimal places."""
40
+ factor = 10**digits
41
+ return floor(number * factor) / factor
@@ -0,0 +1,38 @@
1
+ """Canonical regex patterns for Iranian identifiers.
2
+
3
+ The union of the per-project ``constants.py`` patterns (Cheshmeh, Etka,
4
+ Bazargah, Xpress, Depository). Plain strings so they can be used in model
5
+ ``RegexValidator``\\ s, serializers and plain ``re`` alike.
6
+ """
7
+
8
+ #: Canonical national spelling: 09xxxxxxxxx.
9
+ IRANIAN_MOBILE_PATTERN = r"^09\d{9}$"
10
+
11
+ #: Every accepted spelling: 09…, +989…, 00989…, 989…, bare 9… .
12
+ IRANIAN_MOBILE_ANY_PATTERN = r"^(?:\+98|0098|98|0)?9\d{9}$"
13
+
14
+ #: Hamrahe-Avval (MCI) ranges only (Cheshmeh's MCI_REGEX_PATTERN).
15
+ MCI_MOBILE_PATTERN = r"^0(91[0-9]|99[0-6])\d{7}$"
16
+
17
+ #: Landline / "static" numbers: 0 + area code + number, 11 digits total.
18
+ LANDLINE_PATTERN = r"^0\d{10}$"
19
+
20
+ #: International spelling without '+' as stored by some campaigns: 98… .
21
+ INTL_98_PHONE_PATTERN = r"^98\d{9,14}$"
22
+
23
+ #: Iranian IBAN (شبا): IR + 24 digits. Structure only — use bank.is_valid_iban
24
+ #: for the mod-97 checksum.
25
+ IBAN_PATTERN = r"^IR\d{24}$"
26
+
27
+ #: 10-digit postal code.
28
+ POSTAL_CODE_PATTERN = r"^\d{10}$"
29
+
30
+ #: 16-digit bank card. Structure only — use bank.is_valid_card_number for Luhn.
31
+ CARD_NUMBER_PATTERN = r"^\d{16}$"
32
+
33
+ #: Bank account number.
34
+ ACCOUNT_NUMBER_PATTERN = r"^\d{6,18}$"
35
+
36
+ #: 10-digit national code. Structure only — use national_id.is_valid_national_id
37
+ #: for the mod-11 checksum.
38
+ NATIONAL_ID_PATTERN = r"^\d{10}$"
@@ -0,0 +1,92 @@
1
+ """Iranian phone-number validation and normalization.
2
+
3
+ Replaces the four divergent per-project ``PHONE_REGEX_PATTERN`` constants and
4
+ the Bazargah ``PersianPhoneValidator``: every accepted spelling of an Iranian
5
+ mobile (``09…``, ``+989…``, ``00989…``, ``989…``, bare ``9…``) normalizes to
6
+ the canonical national form ``09xxxxxxxxx`` (or E.164 ``+989xxxxxxxxx``).
7
+ Input digits may be Persian or Arabic-Indic.
8
+
9
+ For non-Iranian / mixed-country numbers, :func:`normalize_phone` validates
10
+ via Google's libphonenumber (the ``[phonenumbers]`` extra) the way the
11
+ Depository backend does.
12
+ """
13
+
14
+ import re
15
+
16
+ from .digits import to_english_digits
17
+
18
+ IRANIAN_MOBILE_RE = re.compile(r"^(?:\+98|0098|98|0)?(9\d{9})$")
19
+
20
+ _SEPARATORS_RE = re.compile(r"[\s\-()]")
21
+
22
+ DEFAULT_REGION = "IR"
23
+
24
+
25
+ def _compact(raw) -> str:
26
+ return _SEPARATORS_RE.sub("", to_english_digits(raw).strip())
27
+
28
+
29
+ def normalize_iranian_mobile(raw) -> str:
30
+ """Any accepted spelling -> canonical ``09xxxxxxxxx``.
31
+
32
+ Raises ``ValueError`` when ``raw`` is not an Iranian mobile number.
33
+ """
34
+ match = IRANIAN_MOBILE_RE.match(_compact(raw))
35
+ if not match:
36
+ raise ValueError(f"Not a valid Iranian mobile number: {raw!r}")
37
+ return "0" + match.group(1)
38
+
39
+
40
+ def to_e164_iranian(raw) -> str:
41
+ """Any accepted spelling -> E.164 ``+989xxxxxxxxx``."""
42
+ return "+98" + normalize_iranian_mobile(raw)[1:]
43
+
44
+
45
+ def is_valid_iranian_mobile(raw) -> bool:
46
+ """True when ``raw`` is an Iranian mobile in any accepted spelling."""
47
+ try:
48
+ normalize_iranian_mobile(raw)
49
+ except ValueError:
50
+ return False
51
+ return True
52
+
53
+
54
+ def normalize_phone(raw, country_code=None) -> str:
55
+ """Validate + normalize an international number to E.164 using
56
+ libphonenumber (``[phonenumbers]`` extra).
57
+
58
+ * ``+…`` / ``00…`` input is validated as-is.
59
+ * ``09…`` is always treated as an Iranian mobile.
60
+ * Other national numbers parse against ``country_code`` (default Iran).
61
+
62
+ Empty input comes back unchanged; invalid numbers raise ``ValueError``.
63
+ """
64
+ try:
65
+ import phonenumbers
66
+ except ImportError as exc: # pragma: no cover - exercised via unit test mock
67
+ raise ImportError(
68
+ "normalize_phone requires the 'phonenumbers' package — install nilva-django-utils[phonenumbers]"
69
+ ) from exc
70
+
71
+ if raw is None:
72
+ return raw
73
+ compact = _compact(raw)
74
+ if not compact:
75
+ return compact
76
+ if compact.startswith("00"):
77
+ compact = "+" + compact[2:]
78
+
79
+ if compact.startswith("+"):
80
+ region = None
81
+ elif compact.startswith("09"):
82
+ region = DEFAULT_REGION
83
+ else:
84
+ region = (country_code or DEFAULT_REGION).upper()
85
+
86
+ try:
87
+ number = phonenumbers.parse(compact, region)
88
+ except phonenumbers.NumberParseException as exc:
89
+ raise ValueError(f"Not a valid phone number: {raw!r}") from exc
90
+ if not phonenumbers.is_valid_number(number):
91
+ raise ValueError(f"Not a valid phone number for its country: {raw!r}")
92
+ return phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164)
@@ -0,0 +1,56 @@
1
+ """Django validators for Iranian identifiers (the ``[django]`` extra).
2
+
3
+ Function validators run the real checksums (national code mod-11, IBAN
4
+ mod-97, card Luhn); the ``RegexValidator`` instances mirror the structure-only
5
+ checks the backends already use, with one canonical pattern each. All work in
6
+ DRF serializers too — DRF converts Django ``ValidationError``\\ s.
7
+ """
8
+
9
+ from django.core.exceptions import ValidationError
10
+ from django.core.validators import RegexValidator
11
+ from django.utils.translation import gettext_lazy as _
12
+
13
+ from . import patterns
14
+ from .bank import is_valid_card_number, is_valid_iban
15
+ from .national_id import is_valid_national_id
16
+ from .phone import is_valid_iranian_mobile
17
+
18
+
19
+ def validate_national_id(value):
20
+ """Structural + mod-11 checksum validation of a کد ملی."""
21
+ if not is_valid_national_id(value):
22
+ raise ValidationError(_("Enter a valid national code."), code="invalid_national_id")
23
+
24
+
25
+ def validate_iranian_mobile(value):
26
+ """Any accepted spelling of an Iranian mobile (09…, +989…, 00989…, 989…)."""
27
+ if not is_valid_iranian_mobile(value):
28
+ raise ValidationError(_("Enter a valid Iranian mobile number."), code="invalid_phone")
29
+
30
+
31
+ def validate_iban(value):
32
+ """Iranian IBAN (شبا) structure + ISO 13616 mod-97 checksum."""
33
+ if not is_valid_iban(value):
34
+ raise ValidationError(_("Enter a valid IBAN."), code="invalid_iban")
35
+
36
+
37
+ def validate_card_number(value):
38
+ """16-digit bank card structure + Luhn checksum."""
39
+ if not is_valid_card_number(value):
40
+ raise ValidationError(_("Enter a valid card number."), code="invalid_card_number")
41
+
42
+
43
+ iranian_mobile_regex_validator = RegexValidator(
44
+ regex=patterns.IRANIAN_MOBILE_PATTERN, message=_("Phone number is not valid")
45
+ )
46
+ mci_mobile_regex_validator = RegexValidator(regex=patterns.MCI_MOBILE_PATTERN, message=_("Phone number is not valid"))
47
+ landline_regex_validator = RegexValidator(regex=patterns.LANDLINE_PATTERN, message=_("Phone number is not valid"))
48
+ iban_regex_validator = RegexValidator(regex=patterns.IBAN_PATTERN, message=_("IBAN is not valid"))
49
+ postal_code_regex_validator = RegexValidator(regex=patterns.POSTAL_CODE_PATTERN, message=_("Postal code is not valid"))
50
+ card_number_regex_validator = RegexValidator(regex=patterns.CARD_NUMBER_PATTERN, message=_("Card number is not valid"))
51
+ account_number_regex_validator = RegexValidator(
52
+ regex=patterns.ACCOUNT_NUMBER_PATTERN, message=_("Account number is not valid")
53
+ )
54
+ national_id_regex_validator = RegexValidator(
55
+ regex=patterns.NATIONAL_ID_PATTERN, message=_("National code is not valid")
56
+ )
@@ -0,0 +1,168 @@
1
+ Metadata-Version: 2.4
2
+ Name: nilva-django-utils
3
+ Version: 0.1.0
4
+ Summary: Nilva house Iranian-locale toolkit — Persian digit normalization, Jalali dates and admin widgets, national-code / phone / IBAN / card validation, shared regex patterns
5
+ Author-email: Nilva <dev@nilva.ai>
6
+ License-Expression: MIT
7
+ Project-URL: homepage, https://gitlab.nilva.ai/nilva/dev-assets/django/nilva-django-utils
8
+ Project-URL: repository, https://gitlab.nilva.ai/nilva/dev-assets/django/nilva-django-utils
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.14
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Framework :: Django
13
+ Classifier: Framework :: Django :: 6.0
14
+ Classifier: Natural Language :: Persian
15
+ Requires-Python: >=3.14
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Provides-Extra: django
19
+ Requires-Dist: django>=6.0; extra == "django"
20
+ Provides-Extra: jalali
21
+ Requires-Dist: jdatetime>=4.1; extra == "jalali"
22
+ Provides-Extra: jalali-admin
23
+ Requires-Dist: django>=6.0; extra == "jalali-admin"
24
+ Requires-Dist: jdatetime>=4.1; extra == "jalali-admin"
25
+ Requires-Dist: django-jalali>=7.0; extra == "jalali-admin"
26
+ Provides-Extra: phonenumbers
27
+ Requires-Dist: phonenumbers>=8.13; extra == "phonenumbers"
28
+ Dynamic: license-file
29
+
30
+ # nilva-django-utils
31
+
32
+ Nilva's house Iranian-locale toolkit for Django and plain-Python services.
33
+ Consolidates the digit-normalization, Jalali-date, national-code, phone,
34
+ bank-identifier and regex-constant code that was previously copy-pasted
35
+ (and drifting) across the Nilva backends.
36
+
37
+ The **core is dependency-free** — `digits`, `numbers`, `national_id`,
38
+ `phone`, `bank`, `patterns` and `conf` are pure standard library. Jalali,
39
+ Django and libphonenumber layers are opt-in extras and are never imported by
40
+ the top-level package.
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ uv add nilva-django-utils # stdlib core only
46
+ uv add "nilva-django-utils[django]" # + Django validators
47
+ uv add "nilva-django-utils[jalali]" # + jdatetime formatting helpers
48
+ uv add "nilva-django-utils[jalali-admin]" # + admin datepicker widgets (django-jalali)
49
+ uv add "nilva-django-utils[phonenumbers]" # + international E.164 normalization
50
+ ```
51
+
52
+ ## Digits — `nilva_django_utils.digits`
53
+
54
+ ```python
55
+ from nilva_django_utils import to_english_digits, to_persian_digits
56
+
57
+ to_english_digits("۱۰۰۰٠٠٠۰۰۷") # "1000000007" (Persian AND Arabic-Indic)
58
+ to_persian_digits(1234567, separate=True) # "۱,۲۳۴,۵۶۷"
59
+ ```
60
+
61
+ `translate_numbers(text, is_separate=False)` is kept as a drop-in for the
62
+ legacy `utils.general.number_utils` signatures.
63
+
64
+ ## Numbers — `nilva_django_utils.numbers`
65
+
66
+ ```python
67
+ convert_to_seconds("1h30m") # 5400 — bare numbers count as seconds
68
+ separate_thousands(1234567) # "1,234,567"
69
+ truncate(1.23456789, 4) # 1.2345 (truncates, never rounds)
70
+ ```
71
+
72
+ ## National code (کد ملی) — `nilva_django_utils.national_id`
73
+
74
+ Real mod-11 checksum (not `isdigit()`), digit-normalized first:
75
+
76
+ ```python
77
+ is_valid_national_id("۱۲۳۴۵۶۷۸۹۱") # True
78
+ is_valid_national_id("1111111111") # False (all-same rejected)
79
+ normalize_national_id("11111119", pad=True) # "0011111119" — Excel ate the zeros
80
+ ```
81
+
82
+ ## Phone — `nilva_django_utils.phone`
83
+
84
+ ```python
85
+ normalize_iranian_mobile("+98 912 345 6789") # "09123456789" (canonical national)
86
+ to_e164_iranian("۰۹۱۲۳۴۵۶۷۸۹") # "+989123456789"
87
+ is_valid_iranian_mobile("9123456789") # True — all house spellings accepted
88
+
89
+ # international numbers, libphonenumber-backed ([phonenumbers] extra):
90
+ normalize_phone("00442071838750") # "+442071838750"
91
+ normalize_phone("2071838750", country_code="GB")
92
+ ```
93
+
94
+ ## Bank — `nilva_django_utils.bank`
95
+
96
+ ```python
97
+ is_valid_iban("IR49 0000 0000 0000 0000 0000 00") # ISO 13616 mod-97
98
+ is_valid_card_number("6104-3377-1234-5675") # Luhn
99
+ ```
100
+
101
+ ## Patterns — `nilva_django_utils.patterns`
102
+
103
+ One canonical constant per identifier (mobile, MCI-only ranges, landline,
104
+ IBAN, postal code, card, account, national id) replacing the divergent
105
+ per-project `PHONE_REGEX_PATTERN`s. Structure-only — pair with the checksum
106
+ helpers above where correctness matters.
107
+
108
+ ## Django validators — `nilva_django_utils.validators`
109
+
110
+ ```python
111
+ from nilva_django_utils.validators import (
112
+ validate_national_id, validate_iranian_mobile, validate_iban, validate_card_number,
113
+ iranian_mobile_regex_validator, iban_regex_validator, postal_code_regex_validator,
114
+ )
115
+
116
+ class Profile(models.Model):
117
+ national_id = models.CharField(max_length=10, validators=[validate_national_id])
118
+ phone = models.CharField(max_length=13, validators=[validate_iranian_mobile])
119
+ sheba = models.CharField(max_length=26, validators=[validate_iban])
120
+ ```
121
+
122
+ They work unchanged in DRF serializers (DRF converts Django `ValidationError`s).
123
+
124
+ ## Jalali — `nilva_django_utils.jalali` / `.admin`
125
+
126
+ House convention: the database and the API stay **gregorian**; Jalali only
127
+ appears at the display/input boundary.
128
+
129
+ ```python
130
+ from nilva_django_utils.jalali import to_jalali, to_jalali_datetime, parse_jalali
131
+
132
+ to_jalali(obj.created_at) # "1403-01-01"
133
+ to_jalali_datetime(obj.created_at) # "1403-01-01 08:30" (active TZ when Django is up)
134
+ parse_jalali("۱۴۰۳/۰۱/۰۱") # datetime.date(2024, 3, 20)
135
+ ```
136
+
137
+ Admin datepickers over gregorian `DateField`s (`[jalali-admin]` extra):
138
+
139
+ ```python
140
+ from nilva_django_utils.admin import JalaliDateAdminMixin, jalali_datetime_display
141
+
142
+ @admin.register(Delivery)
143
+ class DeliveryAdmin(JalaliDateAdminMixin, admin.ModelAdmin):
144
+ readonly_fields = ("created_jalali",)
145
+
146
+ @admin.display(description="ایجاد")
147
+ def created_jalali(self, obj):
148
+ return jalali_datetime_display(obj.created_at)
149
+ ```
150
+
151
+ Add the datepicker static files exactly as Shahab does:
152
+
153
+ ```python
154
+ # settings.py
155
+ from nilva_django_utils.conf import JALALI_SETTINGS # noqa: F401
156
+ ```
157
+
158
+ Projects that store `jmodels.jDateField` natively (Shahab) or use
159
+ `django-jalali-date` mixins (Etka/Bazargah/Xpress) keep working as-is — this
160
+ module is for the gregorian-DB + Jalali-admin convention (Depository).
161
+
162
+ ## Development
163
+
164
+ ```bash
165
+ uv sync --group dev
166
+ ./run_tests.sh # ./run_tests.sh -c for coverage
167
+ uv run ruff check .
168
+ ```
@@ -0,0 +1,16 @@
1
+ nilva_django_utils/__init__.py,sha256=FoeAa_S0_lO4wYQyGQvg-V_hJkgAvOwPnUEVy5QvvcU,1735
2
+ nilva_django_utils/admin.py,sha256=Ax0pLdweHqRSI-f6P8xBbIp4p8WZDwc0t8M1AXTRYEI,2908
3
+ nilva_django_utils/bank.py,sha256=DE7rn9gp78jxGI12BbPqyUxvXY4LLJwUPZSInKmlSNM,1443
4
+ nilva_django_utils/conf.py,sha256=u5-TejRf2Zm_0XuVV4aTCWMGqftBqBpnJD1a6Nak6d8,1052
5
+ nilva_django_utils/digits.py,sha256=n1YanqlPRxWoj-d7CX_Md4NtcpbbnBh-4HTZYJMa8PI,1607
6
+ nilva_django_utils/jalali.py,sha256=zkxFYL5dfc1gUM09vwDrXH66teTwnhLZwjkYjMEPe3c,2508
7
+ nilva_django_utils/national_id.py,sha256=SnaxKo4o8JJd8kTsmDPXzWvn-t3p-EDuZX2cfD0HxEU,1383
8
+ nilva_django_utils/numbers.py,sha256=BXj2kPBx7sY03Ak1OtgFSbZUIT-y6Terdd8Kf_zRoDI,1475
9
+ nilva_django_utils/patterns.py,sha256=0UlP92EFtHY868PiZQQvMfgRc6p149p-I0yEYb8sXIc,1352
10
+ nilva_django_utils/phone.py,sha256=W-PpQ9a3wfEIZ8kTd8AdUkWLPAFYfPMyG6u2B2738nc,3083
11
+ nilva_django_utils/validators.py,sha256=ah_0szHrtAPvTUO3yvV0rR7B34K2bAFCpDD7t9Ule78,2600
12
+ nilva_django_utils-0.1.0.dist-info/licenses/LICENSE,sha256=GaZw6y1gmDVsgrV6Kbba2e0GWozUuG_DP41_BT7fHUg,1061
13
+ nilva_django_utils-0.1.0.dist-info/METADATA,sha256=yx3Zu1-TXmmPfU98yxNpgDOw8duLKbXOp-KuD6oPEm0,6290
14
+ nilva_django_utils-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
15
+ nilva_django_utils-0.1.0.dist-info/top_level.txt,sha256=-JJLTX01Mm4wlckXnnkKXdL89WlMAajkyzXg3Jr_OwQ,19
16
+ nilva_django_utils-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Nilva
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ nilva_django_utils