nilva-django-utils 0.1.0__tar.gz

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.
Files changed (31) hide show
  1. nilva_django_utils-0.1.0/LICENSE +21 -0
  2. nilva_django_utils-0.1.0/PKG-INFO +168 -0
  3. nilva_django_utils-0.1.0/README.md +139 -0
  4. nilva_django_utils-0.1.0/nilva_django_utils/__init__.py +46 -0
  5. nilva_django_utils-0.1.0/nilva_django_utils/admin.py +81 -0
  6. nilva_django_utils-0.1.0/nilva_django_utils/bank.py +43 -0
  7. nilva_django_utils-0.1.0/nilva_django_utils/conf.py +27 -0
  8. nilva_django_utils-0.1.0/nilva_django_utils/digits.py +44 -0
  9. nilva_django_utils-0.1.0/nilva_django_utils/jalali.py +71 -0
  10. nilva_django_utils-0.1.0/nilva_django_utils/national_id.py +35 -0
  11. nilva_django_utils-0.1.0/nilva_django_utils/numbers.py +41 -0
  12. nilva_django_utils-0.1.0/nilva_django_utils/patterns.py +38 -0
  13. nilva_django_utils-0.1.0/nilva_django_utils/phone.py +92 -0
  14. nilva_django_utils-0.1.0/nilva_django_utils/validators.py +56 -0
  15. nilva_django_utils-0.1.0/nilva_django_utils.egg-info/PKG-INFO +168 -0
  16. nilva_django_utils-0.1.0/nilva_django_utils.egg-info/SOURCES.txt +29 -0
  17. nilva_django_utils-0.1.0/nilva_django_utils.egg-info/dependency_links.txt +1 -0
  18. nilva_django_utils-0.1.0/nilva_django_utils.egg-info/requires.txt +14 -0
  19. nilva_django_utils-0.1.0/nilva_django_utils.egg-info/top_level.txt +1 -0
  20. nilva_django_utils-0.1.0/pyproject.toml +65 -0
  21. nilva_django_utils-0.1.0/setup.cfg +4 -0
  22. nilva_django_utils-0.1.0/tests/test_admin.py +52 -0
  23. nilva_django_utils-0.1.0/tests/test_bank.py +47 -0
  24. nilva_django_utils-0.1.0/tests/test_digits.py +39 -0
  25. nilva_django_utils-0.1.0/tests/test_jalali.py +56 -0
  26. nilva_django_utils-0.1.0/tests/test_national_id.py +43 -0
  27. nilva_django_utils-0.1.0/tests/test_numbers.py +46 -0
  28. nilva_django_utils-0.1.0/tests/test_package.py +27 -0
  29. nilva_django_utils-0.1.0/tests/test_patterns.py +45 -0
  30. nilva_django_utils-0.1.0/tests/test_phone.py +62 -0
  31. nilva_django_utils-0.1.0/tests/test_validators.py +52 -0
@@ -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,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,139 @@
1
+ # nilva-django-utils
2
+
3
+ Nilva's house Iranian-locale toolkit for Django and plain-Python services.
4
+ Consolidates the digit-normalization, Jalali-date, national-code, phone,
5
+ bank-identifier and regex-constant code that was previously copy-pasted
6
+ (and drifting) across the Nilva backends.
7
+
8
+ The **core is dependency-free** — `digits`, `numbers`, `national_id`,
9
+ `phone`, `bank`, `patterns` and `conf` are pure standard library. Jalali,
10
+ Django and libphonenumber layers are opt-in extras and are never imported by
11
+ the top-level package.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ uv add nilva-django-utils # stdlib core only
17
+ uv add "nilva-django-utils[django]" # + Django validators
18
+ uv add "nilva-django-utils[jalali]" # + jdatetime formatting helpers
19
+ uv add "nilva-django-utils[jalali-admin]" # + admin datepicker widgets (django-jalali)
20
+ uv add "nilva-django-utils[phonenumbers]" # + international E.164 normalization
21
+ ```
22
+
23
+ ## Digits — `nilva_django_utils.digits`
24
+
25
+ ```python
26
+ from nilva_django_utils import to_english_digits, to_persian_digits
27
+
28
+ to_english_digits("۱۰۰۰٠٠٠۰۰۷") # "1000000007" (Persian AND Arabic-Indic)
29
+ to_persian_digits(1234567, separate=True) # "۱,۲۳۴,۵۶۷"
30
+ ```
31
+
32
+ `translate_numbers(text, is_separate=False)` is kept as a drop-in for the
33
+ legacy `utils.general.number_utils` signatures.
34
+
35
+ ## Numbers — `nilva_django_utils.numbers`
36
+
37
+ ```python
38
+ convert_to_seconds("1h30m") # 5400 — bare numbers count as seconds
39
+ separate_thousands(1234567) # "1,234,567"
40
+ truncate(1.23456789, 4) # 1.2345 (truncates, never rounds)
41
+ ```
42
+
43
+ ## National code (کد ملی) — `nilva_django_utils.national_id`
44
+
45
+ Real mod-11 checksum (not `isdigit()`), digit-normalized first:
46
+
47
+ ```python
48
+ is_valid_national_id("۱۲۳۴۵۶۷۸۹۱") # True
49
+ is_valid_national_id("1111111111") # False (all-same rejected)
50
+ normalize_national_id("11111119", pad=True) # "0011111119" — Excel ate the zeros
51
+ ```
52
+
53
+ ## Phone — `nilva_django_utils.phone`
54
+
55
+ ```python
56
+ normalize_iranian_mobile("+98 912 345 6789") # "09123456789" (canonical national)
57
+ to_e164_iranian("۰۹۱۲۳۴۵۶۷۸۹") # "+989123456789"
58
+ is_valid_iranian_mobile("9123456789") # True — all house spellings accepted
59
+
60
+ # international numbers, libphonenumber-backed ([phonenumbers] extra):
61
+ normalize_phone("00442071838750") # "+442071838750"
62
+ normalize_phone("2071838750", country_code="GB")
63
+ ```
64
+
65
+ ## Bank — `nilva_django_utils.bank`
66
+
67
+ ```python
68
+ is_valid_iban("IR49 0000 0000 0000 0000 0000 00") # ISO 13616 mod-97
69
+ is_valid_card_number("6104-3377-1234-5675") # Luhn
70
+ ```
71
+
72
+ ## Patterns — `nilva_django_utils.patterns`
73
+
74
+ One canonical constant per identifier (mobile, MCI-only ranges, landline,
75
+ IBAN, postal code, card, account, national id) replacing the divergent
76
+ per-project `PHONE_REGEX_PATTERN`s. Structure-only — pair with the checksum
77
+ helpers above where correctness matters.
78
+
79
+ ## Django validators — `nilva_django_utils.validators`
80
+
81
+ ```python
82
+ from nilva_django_utils.validators import (
83
+ validate_national_id, validate_iranian_mobile, validate_iban, validate_card_number,
84
+ iranian_mobile_regex_validator, iban_regex_validator, postal_code_regex_validator,
85
+ )
86
+
87
+ class Profile(models.Model):
88
+ national_id = models.CharField(max_length=10, validators=[validate_national_id])
89
+ phone = models.CharField(max_length=13, validators=[validate_iranian_mobile])
90
+ sheba = models.CharField(max_length=26, validators=[validate_iban])
91
+ ```
92
+
93
+ They work unchanged in DRF serializers (DRF converts Django `ValidationError`s).
94
+
95
+ ## Jalali — `nilva_django_utils.jalali` / `.admin`
96
+
97
+ House convention: the database and the API stay **gregorian**; Jalali only
98
+ appears at the display/input boundary.
99
+
100
+ ```python
101
+ from nilva_django_utils.jalali import to_jalali, to_jalali_datetime, parse_jalali
102
+
103
+ to_jalali(obj.created_at) # "1403-01-01"
104
+ to_jalali_datetime(obj.created_at) # "1403-01-01 08:30" (active TZ when Django is up)
105
+ parse_jalali("۱۴۰۳/۰۱/۰۱") # datetime.date(2024, 3, 20)
106
+ ```
107
+
108
+ Admin datepickers over gregorian `DateField`s (`[jalali-admin]` extra):
109
+
110
+ ```python
111
+ from nilva_django_utils.admin import JalaliDateAdminMixin, jalali_datetime_display
112
+
113
+ @admin.register(Delivery)
114
+ class DeliveryAdmin(JalaliDateAdminMixin, admin.ModelAdmin):
115
+ readonly_fields = ("created_jalali",)
116
+
117
+ @admin.display(description="ایجاد")
118
+ def created_jalali(self, obj):
119
+ return jalali_datetime_display(obj.created_at)
120
+ ```
121
+
122
+ Add the datepicker static files exactly as Shahab does:
123
+
124
+ ```python
125
+ # settings.py
126
+ from nilva_django_utils.conf import JALALI_SETTINGS # noqa: F401
127
+ ```
128
+
129
+ Projects that store `jmodels.jDateField` natively (Shahab) or use
130
+ `django-jalali-date` mixins (Etka/Bazargah/Xpress) keep working as-is — this
131
+ module is for the gregorian-DB + Jalali-admin convention (Depository).
132
+
133
+ ## Development
134
+
135
+ ```bash
136
+ uv sync --group dev
137
+ ./run_tests.sh # ./run_tests.sh -c for coverage
138
+ uv run ruff check .
139
+ ```
@@ -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