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.
- nilva_django_utils/__init__.py +46 -0
- nilva_django_utils/admin.py +81 -0
- nilva_django_utils/bank.py +43 -0
- nilva_django_utils/conf.py +27 -0
- nilva_django_utils/digits.py +44 -0
- nilva_django_utils/jalali.py +71 -0
- nilva_django_utils/national_id.py +35 -0
- nilva_django_utils/numbers.py +41 -0
- nilva_django_utils/patterns.py +38 -0
- nilva_django_utils/phone.py +92 -0
- nilva_django_utils/validators.py +56 -0
- nilva_django_utils-0.1.0.dist-info/METADATA +168 -0
- nilva_django_utils-0.1.0.dist-info/RECORD +16 -0
- nilva_django_utils-0.1.0.dist-info/WHEEL +5 -0
- nilva_django_utils-0.1.0.dist-info/licenses/LICENSE +21 -0
- nilva_django_utils-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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,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
|