Browse Source
feat(transmitters): implement canonical date system, YearValue models, and status descriptions
feat(transmitters): implement canonical date system, YearValue models, and status descriptions
- Add date_engine utility for parsing exact, range, circa, before, after, and #context dates - Introduce YearValue and NarratorDate models to index and translate narrator dates - Add description field to OpinionStatus and TransmitterReliability models - Register DovodiYearValueViewSet and DovodiBookAuthorRutbaViewSet admin endpoints - Add parse-preview action for validating and translating date inputs - Add migration 0055 to populate YearValue and NarratorDate from existing transmittersmaster
10 changed files with 2521 additions and 837 deletions
-
64apps/hadis/admin/transmitter.py
-
156apps/hadis/migrations/0055_yearvalue_narratordate_and_transmitters_date_fields.py
-
333apps/hadis/models/transmitter.py
-
143apps/hadis/serializers/hadis.py
-
224apps/hadis/serializers/serializers_admin.py
-
6apps/hadis/urls.py
-
12apps/hadis/utils/__init__.py
-
680apps/hadis/utils/date_engine.py
-
36apps/hadis/views/transmitter.py
-
128apps/hadis/views_admin.py
@ -0,0 +1,156 @@ |
|||||
|
# Generated by Django 4.2.30 on 2026-10-19 14:12 |
||||
|
|
||||
|
from django.db import migrations, models |
||||
|
import django.db.models.deletion |
||||
|
|
||||
|
|
||||
|
def populate_initial_year_values_and_narrator_dates(apps, schema_editor): |
||||
|
Transmitters = apps.get_model('hadis', 'Transmitters') |
||||
|
YearValue = apps.get_model('hadis', 'YearValue') |
||||
|
NarratorDate = apps.get_model('hadis', 'NarratorDate') |
||||
|
|
||||
|
from apps.hadis.utils.date_engine import ( |
||||
|
DEFAULT_CONTEXT_TAGS, |
||||
|
parse_canonical_date, |
||||
|
build_translations_for_parsed, |
||||
|
) |
||||
|
|
||||
|
# 1. Seed default #context tags |
||||
|
for tag, lang_map in DEFAULT_CONTEXT_TAGS.items(): |
||||
|
translations = [ |
||||
|
{"language_code": "ru", "text": lang_map.get("ru", tag)}, |
||||
|
{"language_code": "en", "text": lang_map.get("en", tag)}, |
||||
|
{"language_code": "ar", "text": lang_map.get("ar", tag)}, |
||||
|
] |
||||
|
for cat in ('birth', 'death'): |
||||
|
YearValue.objects.get_or_create( |
||||
|
category=cat, |
||||
|
raw_value=tag, |
||||
|
defaults={ |
||||
|
'parse_type': 'context', |
||||
|
'translations': translations, |
||||
|
} |
||||
|
) |
||||
|
|
||||
|
# 2. Populate YearValue and NarratorDate for existing Transmitters |
||||
|
yv_cache = {} |
||||
|
for tx in Transmitters.objects.all().iterator(): |
||||
|
for date_type, raw_val in ( |
||||
|
('birth', tx.birth_year_hijri), |
||||
|
('death', tx.death_year_hijri), |
||||
|
('age', tx.age_at_death), |
||||
|
): |
||||
|
if raw_val is None or str(raw_val).strip() == '': |
||||
|
continue |
||||
|
raw_str = str(raw_val).strip() |
||||
|
parsed = parse_canonical_date(raw_str, category=date_type) |
||||
|
canonical_str = parsed.raw_value if parsed.is_valid else raw_str |
||||
|
|
||||
|
cache_key = (date_type, canonical_str) |
||||
|
yv = yv_cache.get(cache_key) |
||||
|
if not yv: |
||||
|
yv = YearValue.objects.filter(category=date_type, raw_value=canonical_str).first() |
||||
|
if not yv: |
||||
|
if parsed.is_valid: |
||||
|
translations = build_translations_for_parsed(parsed, category=date_type) |
||||
|
else: |
||||
|
translations = [ |
||||
|
{"language_code": "ru", "text": canonical_str}, |
||||
|
{"language_code": "en", "text": canonical_str}, |
||||
|
{"language_code": "ar", "text": canonical_str}, |
||||
|
] |
||||
|
yv = YearValue.objects.create( |
||||
|
category=date_type, |
||||
|
raw_value=canonical_str, |
||||
|
parse_type=parsed.parse_type if parsed.is_valid else 'unparsed', |
||||
|
value=parsed.value if parsed.is_valid else None, |
||||
|
value_min=parsed.value_min if parsed.is_valid else None, |
||||
|
value_max=parsed.value_max if parsed.is_valid else None, |
||||
|
values_list=parsed.values_list if parsed.is_valid else [], |
||||
|
translations=translations, |
||||
|
) |
||||
|
yv_cache[cache_key] = yv |
||||
|
|
||||
|
NarratorDate.objects.update_or_create( |
||||
|
transmitter_id=tx.id, |
||||
|
date_type=date_type, |
||||
|
defaults={ |
||||
|
'year_value_id': yv.id, |
||||
|
'raw_value': yv.raw_value, |
||||
|
} |
||||
|
) |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
dependencies = [ |
||||
|
('hadis', '0054_bookauthorrutba_opinionstatus_description_and_more'), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.AlterField( |
||||
|
model_name='transmitters', |
||||
|
name='birth_year_hijri', |
||||
|
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Birth Year (Hijri)'), |
||||
|
), |
||||
|
migrations.AlterField( |
||||
|
model_name='transmitters', |
||||
|
name='death_year_hijri', |
||||
|
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Death Year (Hijri)'), |
||||
|
), |
||||
|
migrations.AlterField( |
||||
|
model_name='transmitters', |
||||
|
name='age_at_death', |
||||
|
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Age at Death'), |
||||
|
), |
||||
|
migrations.CreateModel( |
||||
|
name='YearValue', |
||||
|
fields=[ |
||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
|
('category', models.CharField(choices=[('birth', 'Birth'), ('death', 'Death'), ('age', 'Age')], db_index=True, max_length=20, verbose_name='Category')), |
||||
|
('raw_value', models.CharField(db_index=True, max_length=100, verbose_name='Canonical Raw Value')), |
||||
|
('parse_type', models.CharField(choices=[('exact', 'Exact'), ('range', 'Range'), ('circa', 'Circa (~)'), ('greater_than', 'Greater Than (>)'), ('less_than', 'Less Than (<)'), ('alternatives', 'Alternatives (2)'), ('alternatives_multi', 'Alternatives (3+)'), ('alternatives_range', 'Alternative Ranges (2)'), ('alternatives_range_multi', 'Alternative Ranges (3+)'), ('context', 'Context (#tag)'), ('unparsed', 'Unparsed')], db_index=True, default='exact', max_length=40, verbose_name='Parse Type')), |
||||
|
('value', models.IntegerField(blank=True, db_index=True, null=True, verbose_name='Exact Value')), |
||||
|
('value_min', models.IntegerField(blank=True, db_index=True, null=True, verbose_name='Min Value')), |
||||
|
('value_max', models.IntegerField(blank=True, db_index=True, null=True, verbose_name='Max Value')), |
||||
|
('values_list', models.JSONField(blank=True, default=list, verbose_name='Values List')), |
||||
|
('translations', models.JSONField(blank=True, default=list, verbose_name='Translations (ru, en, ar)')), |
||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), |
||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), |
||||
|
], |
||||
|
options={ |
||||
|
'verbose_name': 'Year Value', |
||||
|
'verbose_name_plural': 'Year Values', |
||||
|
'ordering': ('category', 'value_min', 'raw_value'), |
||||
|
'unique_together': {('category', 'raw_value')}, |
||||
|
'indexes': [ |
||||
|
models.Index(fields=['category', 'value'], name='hadis_yearv_categor_idx'), |
||||
|
models.Index(fields=['category', 'value_min', 'value_max'], name='hadis_yearv_cat_min_max_idx'), |
||||
|
models.Index(fields=['parse_type'], name='hadis_yearv_parse_t_idx'), |
||||
|
], |
||||
|
}, |
||||
|
), |
||||
|
migrations.CreateModel( |
||||
|
name='NarratorDate', |
||||
|
fields=[ |
||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
|
('date_type', models.CharField(choices=[('birth', 'Birth'), ('death', 'Death'), ('age', 'Age')], db_index=True, max_length=20, verbose_name='Date Type')), |
||||
|
('raw_value', models.CharField(max_length=100, verbose_name='Canonical Raw Value')), |
||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), |
||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), |
||||
|
('transmitter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='narrator_dates', to='hadis.transmitters', verbose_name='Transmitter')), |
||||
|
('year_value', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='narrator_dates', to='hadis.yearvalue', verbose_name='Year Value')), |
||||
|
], |
||||
|
options={ |
||||
|
'verbose_name': 'Narrator Date', |
||||
|
'verbose_name_plural': 'Narrator Dates', |
||||
|
'ordering': ('transmitter_id', 'date_type'), |
||||
|
'unique_together': {('transmitter', 'date_type')}, |
||||
|
'indexes': [ |
||||
|
models.Index(fields=['date_type', 'year_value'], name='hadis_narra_date_ty_idx'), |
||||
|
models.Index(fields=['transmitter', 'date_type'], name='hadis_narra_transmi_idx'), |
||||
|
], |
||||
|
}, |
||||
|
), |
||||
|
migrations.RunPython(populate_initial_year_values_and_narrator_dates, migrations.RunPython.noop), |
||||
|
] |
||||
@ -0,0 +1,12 @@ |
|||||
|
from .date_engine import ( |
||||
|
SUPPORTED_LANGS, |
||||
|
DEFAULT_TEMPLATES, |
||||
|
DEFAULT_CONTEXT_TAGS, |
||||
|
CanonicalDateValidationError, |
||||
|
parse_canonical_date, |
||||
|
build_translations_for_parsed, |
||||
|
render_date_from_parsed, |
||||
|
format_canonical_for_all_langs, |
||||
|
suggest_canonical_fix, |
||||
|
normalize_lang_code, |
||||
|
) |
||||
@ -0,0 +1,680 @@ |
|||||
|
import re |
||||
|
from dataclasses import dataclass, field |
||||
|
from typing import Dict, Any, Optional, List, Tuple |
||||
|
|
||||
|
SUPPORTED_LANGS = ("ru", "en", "ar") |
||||
|
|
||||
|
# Default templates matching the specification in Коллекции дат.md |
||||
|
# Tuple structure: (display_template, display_template_negative, display_template_age) |
||||
|
DEFAULT_TEMPLATES: Dict[str, Dict[str, Tuple[Optional[str], Optional[str], Optional[str]]]] = { |
||||
|
"exact": { |
||||
|
"ar": ("{value} هـ", "{abs_value} ق.هـ", "{value} سنة"), |
||||
|
"ru": ("{value} г.", "{abs_value} г. до хиджры", "{value} г."), |
||||
|
"en": ("{value} AH", "{abs_value} BH", "{value} years"), |
||||
|
}, |
||||
|
"range": { |
||||
|
"ar": ("{min}–{max} هـ", "{abs_min} ق.هـ – {max} هـ", "{min}–{max} سنة"), |
||||
|
"ru": ("{min}–{max} гг.", "{abs_min} г. до хиджры – {max} г.", "{min}–{max} лет"), |
||||
|
"en": ("{min}–{max} AH", "{abs_min} BH – {max} AH", "{min}–{max} years"), |
||||
|
}, |
||||
|
"circa": { |
||||
|
"ar": ("حوالي {value} هـ", "حوالي {abs_value} ق.هـ", "حوالي {value} سنة"), |
||||
|
"ru": ("около {value} г.", "около {abs_value} г. до хиджры", "около {value} лет"), |
||||
|
"en": ("circa {value} AH", "circa {abs_value} BH", "circa {value} years"), |
||||
|
}, |
||||
|
"greater_than": { |
||||
|
"ar": ("بعد {min} هـ", "بعد {abs_min} ق.هـ", "أكثر من {min} سنة"), |
||||
|
"ru": ("после {min} г.", "после {abs_min} г. до хиджры", "более {min} лет"), |
||||
|
"en": ("after {min} AH", "after {abs_min} BH", "more than {min} years"), |
||||
|
}, |
||||
|
"less_than": { |
||||
|
"ar": ("قبل {max} هـ", "قبل {abs_max} ق.هـ", "أقل من {max} سنة"), |
||||
|
"ru": ("до {max} г.", "до {abs_max} г. до хиджры", "менее {max} лет"), |
||||
|
"en": ("before {max} AH", "before {abs_max} BH", "less than {max} years"), |
||||
|
}, |
||||
|
"alternatives": { |
||||
|
"ar": ("{min} أو {max}", None, "{min} أو {max} سنة"), |
||||
|
"ru": ("{min} или {max}", None, "{min} или {max} лет"), |
||||
|
"en": ("{min} or {max}", None, "{min} or {max} years"), |
||||
|
}, |
||||
|
"alternatives_range": { |
||||
|
"ar": ("{range1} أو {range2}", None, "{range1} أو {range2} سنة"), |
||||
|
"ru": ("{range1} или {range2}", None, "{range1} или {range2} лет"), |
||||
|
"en": ("{range1} or {range2}", None, "{range1} or {range2} years"), |
||||
|
}, |
||||
|
"context": { |
||||
|
"ar": ("", "", ""), |
||||
|
"ru": ("", "", ""), |
||||
|
"en": ("", "", ""), |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
DEFAULT_CONTEXT_TAGS: Dict[str, Dict[str, str]] = { |
||||
|
"#hijra_habasha": { |
||||
|
"ar": "وُلِدَ فِي الْحَبَشَةِ فِي فَتْرَةِ الْهِجْرَةِ الْأُولَى", |
||||
|
"ru": "Родился в Абиссинии во время первого переселения", |
||||
|
"en": "Born in Abyssinia during the first migration", |
||||
|
}, |
||||
|
"#early_islam": { |
||||
|
"ar": "صدر الإسلام", |
||||
|
"ru": "Ранний ислам", |
||||
|
"en": "Early Islam", |
||||
|
}, |
||||
|
"#umayyad_era": { |
||||
|
"ar": "العصر الأموي", |
||||
|
"ru": "Эпоха Омейядов", |
||||
|
"en": "Umayyad era", |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
# Regex patterns for strict validation |
||||
|
RE_EXACT = re.compile(r"^-?\d+$") |
||||
|
RE_RANGE = re.compile(r"^(-?\d+)\.\.(-?\d+)$") |
||||
|
RE_CIRCA = re.compile(r"^~(-?\d+)$") |
||||
|
RE_GREATER = re.compile(r"^>(-?\d+)$") |
||||
|
RE_LESS = re.compile(r"^<(-?\d+)$") |
||||
|
RE_ALT_EXACT = re.compile(r"^-?\d+(?:;-?\d+)+$") |
||||
|
RE_ALT_RANGE = re.compile(r"^-?\d+\.\.-?\d+(?:;-?\d+\.\.-?\d+)+$") |
||||
|
RE_CONTEXT = re.compile(r"^#([a-zA-Z0-9_]+)$") |
||||
|
|
||||
|
|
||||
|
class CanonicalDateValidationError(ValueError): |
||||
|
def __init__(self, message: str, suggestion: Optional[str] = None): |
||||
|
super().__init__(message) |
||||
|
self.message = message |
||||
|
self.suggestion = suggestion |
||||
|
|
||||
|
|
||||
|
@dataclass |
||||
|
class ParsedCanonicalDate: |
||||
|
raw_value: str |
||||
|
is_valid: bool = True |
||||
|
parse_type: str = "exact" |
||||
|
value: Optional[int] = None |
||||
|
value_min: Optional[int] = None |
||||
|
value_max: Optional[int] = None |
||||
|
values_list: List[int] = field(default_factory=list) |
||||
|
abs_value: Optional[int] = None |
||||
|
is_bh: bool = False |
||||
|
is_mixed: bool = False |
||||
|
context_key: Optional[str] = None |
||||
|
error: Optional[str] = None |
||||
|
suggestion: Optional[str] = None |
||||
|
translations: List[Dict[str, Any]] = field(default_factory=list) |
||||
|
_threshold: Optional[int] = None |
||||
|
_parts: List[int] = field(default_factory=list) |
||||
|
_ranges: List[Tuple[int, int]] = field(default_factory=list) |
||||
|
|
||||
|
@property |
||||
|
def canonical(self) -> str: |
||||
|
return self.raw_value |
||||
|
|
||||
|
@property |
||||
|
def type(self) -> str: |
||||
|
return self.parse_type |
||||
|
|
||||
|
def get(self, key: str, default: Any = None) -> Any: |
||||
|
return getattr(self, key, default) |
||||
|
|
||||
|
def __getitem__(self, key: str) -> Any: |
||||
|
return getattr(self, key) |
||||
|
|
||||
|
|
||||
|
def normalize_lang_code(lang: Optional[str]) -> str: |
||||
|
if not lang: |
||||
|
return "en" |
||||
|
code = lang.split(",")[0].split(";")[0].split("-")[0].split("_")[0].strip().lower() |
||||
|
if code in SUPPORTED_LANGS: |
||||
|
return code |
||||
|
return "en" |
||||
|
|
||||
|
|
||||
|
def suggest_canonical_fix(raw: str) -> Optional[str]: |
||||
|
""" |
||||
|
Attempts to auto-fix common operator mistakes described in the standard: |
||||
|
- '170-193' or '170 – 193' or '170...193' -> '170..193' |
||||
|
- '±100' or '≈100' or '~ 100' -> '~100' |
||||
|
- '> 80' -> '>80', '< 150' -> '<150' |
||||
|
- '131, 140' or '131; 140' or '131 или 140' or '131 or 140' -> '131;140' |
||||
|
""" |
||||
|
if not raw or not isinstance(raw, str): |
||||
|
return None |
||||
|
s = raw.strip() |
||||
|
if not s: |
||||
|
return None |
||||
|
|
||||
|
# Context tag cleanup |
||||
|
if s.startswith("#"): |
||||
|
cleaned_key = re.sub(r"[^a-zA-Z0-9_]", "_", s[1:].strip()).strip("_").lower() |
||||
|
return f"#{cleaned_key}" if cleaned_key else None |
||||
|
|
||||
|
# Replace approximate symbols |
||||
|
if s.startswith(("±", "≈")): |
||||
|
s = "~" + s[1:].strip() |
||||
|
elif s.startswith("~"): |
||||
|
s = "~" + s[1:].strip() |
||||
|
|
||||
|
# Replace greater/less spacing or words |
||||
|
s = re.sub(r"^(?:больше|more\s+than|after|بعد)\s*", ">", s, flags=re.IGNORECASE) |
||||
|
s = re.sub(r"^(?:меньше|до|less\s+than|before|قبل)\s*", "<", s, flags=re.IGNORECASE) |
||||
|
s = re.sub(r"([><~])\s+(?=-?\d)", r"\1", s) |
||||
|
|
||||
|
# Replace alternative separators ('или', 'or', 'أو', '∨', ',') with ';' |
||||
|
s = re.sub(r"\s*(?:или|or|أو|∨|,|،|;)\s*", ";", s, flags=re.IGNORECASE) |
||||
|
|
||||
|
# Replace range separators ('...', '–', '—', or '-' between two positive numbers) |
||||
|
s = re.sub(r"\s*\.\.\.+\s*", "..", s) |
||||
|
s = re.sub(r"\s*[–—]\s*", "..", s) |
||||
|
s = re.sub(r"(?<=\d)\s*-\s*(?=\d)", "..", s) |
||||
|
s = re.sub(r"\s*\.\.\s*", "..", s) |
||||
|
|
||||
|
return s if s != raw.strip() else None |
||||
|
|
||||
|
|
||||
|
def _join_multi_items(items: List[str], lang: str) -> str: |
||||
|
if not items: |
||||
|
return "" |
||||
|
if len(items) == 1: |
||||
|
return items[0] |
||||
|
if lang == "ar": |
||||
|
if len(items) == 2: |
||||
|
return f"{items[0]} أو {items[1]}" |
||||
|
return "، ".join(items[:-1]) + f" أو {items[-1]}" |
||||
|
elif lang == "ru": |
||||
|
if len(items) == 2: |
||||
|
return f"{items[0]} или {items[1]}" |
||||
|
return ", ".join(items[:-1]) + f" или {items[-1]}" |
||||
|
else: |
||||
|
if len(items) == 2: |
||||
|
return f"{items[0]} or {items[1]}" |
||||
|
return ", ".join(items[:-1]) + f" or {items[-1]}" |
||||
|
|
||||
|
|
||||
|
def _format_single_range(r_min: int, r_max: int, lang: str, for_age: bool = False, with_unit: bool = True) -> str: |
||||
|
if for_age: |
||||
|
base = f"{r_min}–{r_max}" |
||||
|
if not with_unit: |
||||
|
return base |
||||
|
unit = {"ar": "سنة", "ru": "лет", "en": "years"}.get(lang, "years") |
||||
|
return f"{base} {unit}" |
||||
|
|
||||
|
# Hijri year range |
||||
|
if r_min < 0 and r_max >= 0: |
||||
|
abs_min = abs(r_min) |
||||
|
if lang == "ar": |
||||
|
return f"{abs_min} ق.هـ – {r_max} هـ" |
||||
|
elif lang == "ru": |
||||
|
return f"{abs_min} г. до хиджры – {r_max} г." |
||||
|
else: |
||||
|
return f"{abs_min} BH – {r_max} AH" |
||||
|
elif r_min < 0 and r_max < 0: |
||||
|
abs_min = abs(r_min) |
||||
|
abs_max = abs(r_max) |
||||
|
if not with_unit: |
||||
|
return f"{abs_min}–{abs_max}" |
||||
|
if lang == "ar": |
||||
|
return f"{abs_min}–{abs_max} ق.هـ" |
||||
|
elif lang == "ru": |
||||
|
return f"{abs_min}–{abs_max} гг. до хиджры" |
||||
|
else: |
||||
|
return f"{abs_min}–{abs_max} BH" |
||||
|
else: |
||||
|
base = f"{r_min}–{r_max}" |
||||
|
if not with_unit: |
||||
|
return base |
||||
|
if lang == "ar": |
||||
|
return f"{base} هـ" |
||||
|
elif lang == "ru": |
||||
|
return f"{base} гг." |
||||
|
else: |
||||
|
return f"{base} AH" |
||||
|
|
||||
|
|
||||
|
def canonical_to_parts(canonical: str) -> List[int]: |
||||
|
return [int(p) for p in canonical.split(";") if p.strip()] |
||||
|
|
||||
|
|
||||
|
def canonical_to_ranges(canonical: str) -> List[Tuple[int, int]]: |
||||
|
res: List[Tuple[int, int]] = [] |
||||
|
for chunk in canonical.split(";"): |
||||
|
if ".." in chunk: |
||||
|
a, b = chunk.split("..", 1) |
||||
|
res.append((int(a), int(b))) |
||||
|
return res |
||||
|
|
||||
|
|
||||
|
def parse_canonical_date( |
||||
|
raw_value: Any, |
||||
|
category: Optional[str] = None, |
||||
|
date_type: Optional[str] = None, |
||||
|
raise_on_error: bool = False, |
||||
|
) -> ParsedCanonicalDate: |
||||
|
""" |
||||
|
Validates and parses a canonical date/age string according to the Russian specification. |
||||
|
Returns a ParsedCanonicalDate object with `is_valid`, `raw_value`, `parse_type`, `value`, |
||||
|
`value_min`, `value_max`, `values_list`, `error`, `suggestion`, and `translations`. |
||||
|
""" |
||||
|
cat = category or date_type or "birth" |
||||
|
is_age = (cat == "age") |
||||
|
|
||||
|
if raw_value is None: |
||||
|
return ParsedCanonicalDate(raw_value="", is_valid=False, parse_type="unparsed", error="Empty value") |
||||
|
|
||||
|
canonical = str(raw_value).strip() |
||||
|
if not canonical or canonical.lower() in ("null", "none", "undefined"): |
||||
|
return ParsedCanonicalDate(raw_value="", is_valid=False, parse_type="unparsed", error="Empty value") |
||||
|
|
||||
|
suggestion = suggest_canonical_fix(canonical) |
||||
|
|
||||
|
def _invalid(err_msg: str, custom_suggestion: Optional[str] = None) -> ParsedCanonicalDate: |
||||
|
sug = custom_suggestion if custom_suggestion is not None else suggestion |
||||
|
if seg_msg := (f"{err_msg} (Did you mean '{sug}'?)" if sug and sug != canonical else err_msg): |
||||
|
pass |
||||
|
if raise_on_error: |
||||
|
raise CanonicalDateValidationError(seg_msg, suggestion=sug) |
||||
|
return ParsedCanonicalDate( |
||||
|
raw_value=canonical, |
||||
|
is_valid=False, |
||||
|
parse_type="unparsed", |
||||
|
error=seg_msg, |
||||
|
suggestion=sug, |
||||
|
) |
||||
|
|
||||
|
# 1. Exact value (e.g. "143", "-5", "0") |
||||
|
if RE_EXACT.match(canonical): |
||||
|
val = int(canonical) |
||||
|
if is_age and val < 0: |
||||
|
return _invalid("Negative values (Before Hijra) are not allowed for age.", str(abs(val))) |
||||
|
is_bh = val < 0 |
||||
|
parsed = ParsedCanonicalDate( |
||||
|
raw_value=canonical, |
||||
|
is_valid=True, |
||||
|
parse_type="exact", |
||||
|
value=val, |
||||
|
value_min=val, |
||||
|
value_max=val, |
||||
|
values_list=[val], |
||||
|
abs_value=abs(val), |
||||
|
is_bh=is_bh, |
||||
|
is_mixed=False, |
||||
|
context_key=None, |
||||
|
) |
||||
|
parsed.translations = build_translations_for_parsed(parsed, category=cat) |
||||
|
return parsed |
||||
|
|
||||
|
# 2. Range (e.g. "170..193", "-3..5") |
||||
|
m_range = RE_RANGE.match(canonical) |
||||
|
if m_range: |
||||
|
v_min = int(m_range.group(1)) |
||||
|
v_max = int(m_range.group(2)) |
||||
|
if is_age and (v_min < 0 or v_max < 0): |
||||
|
return _invalid("Negative values are not allowed for age ranges.", f"{abs(v_min)}..{abs(v_max)}") |
||||
|
if v_min > v_max: |
||||
|
return _invalid( |
||||
|
f"Range minimum ({v_min}) cannot be greater than maximum ({v_max}).", |
||||
|
f"{v_max}..{v_min}", |
||||
|
) |
||||
|
is_mixed = (v_min < 0 and v_max >= 0) |
||||
|
is_bh = (v_min < 0 and not is_mixed) |
||||
|
parsed = ParsedCanonicalDate( |
||||
|
raw_value=canonical, |
||||
|
is_valid=True, |
||||
|
parse_type="range", |
||||
|
value=None, |
||||
|
value_min=v_min, |
||||
|
value_max=v_max, |
||||
|
values_list=[v_min, v_max], |
||||
|
abs_value=None, |
||||
|
is_bh=is_bh, |
||||
|
is_mixed=is_mixed, |
||||
|
context_key=None, |
||||
|
) |
||||
|
parsed.translations = build_translations_for_parsed(parsed, category=cat) |
||||
|
return parsed |
||||
|
|
||||
|
# 3. Circa / Approximate (e.g. "~100", "~-10") |
||||
|
m_circa = RE_CIRCA.match(canonical) |
||||
|
if m_circa: |
||||
|
val = int(m_circa.group(1)) |
||||
|
if is_age and val < 0: |
||||
|
return _invalid("Negative values are not allowed for age.", f"~{abs(val)}") |
||||
|
parsed = ParsedCanonicalDate( |
||||
|
raw_value=canonical, |
||||
|
is_valid=True, |
||||
|
parse_type="circa", |
||||
|
value=val, |
||||
|
value_min=val, |
||||
|
value_max=val, |
||||
|
values_list=[val], |
||||
|
abs_value=abs(val), |
||||
|
is_bh=val < 0, |
||||
|
is_mixed=False, |
||||
|
context_key=None, |
||||
|
) |
||||
|
parsed.translations = build_translations_for_parsed(parsed, category=cat) |
||||
|
return parsed |
||||
|
|
||||
|
# 4. Greater than (e.g. ">80", ">-5") |
||||
|
m_gt = RE_GREATER.match(canonical) |
||||
|
if m_gt: |
||||
|
val = int(m_gt.group(1)) |
||||
|
if is_age and val < 0: |
||||
|
return _invalid("Negative values are not allowed for age.", f">{abs(val)}") |
||||
|
parsed = ParsedCanonicalDate( |
||||
|
raw_value=canonical, |
||||
|
is_valid=True, |
||||
|
parse_type="greater_than", |
||||
|
value=val, |
||||
|
value_min=val + 1, |
||||
|
value_max=None, |
||||
|
values_list=[val], |
||||
|
abs_value=abs(val), |
||||
|
is_bh=val < 0, |
||||
|
is_mixed=False, |
||||
|
context_key=None, |
||||
|
_threshold=val, |
||||
|
) |
||||
|
parsed.translations = build_translations_for_parsed(parsed, category=cat) |
||||
|
return parsed |
||||
|
|
||||
|
# 5. Less than (e.g. "<150", "<-10") |
||||
|
m_lt = RE_LESS.match(canonical) |
||||
|
if m_lt: |
||||
|
val = int(m_lt.group(1)) |
||||
|
if is_age and val < 0: |
||||
|
return _invalid("Negative values are not allowed for age.", f"<{abs(val)}") |
||||
|
parsed = ParsedCanonicalDate( |
||||
|
raw_value=canonical, |
||||
|
is_valid=True, |
||||
|
parse_type="less_than", |
||||
|
value=val, |
||||
|
value_min=None, |
||||
|
value_max=val - 1, |
||||
|
values_list=[val], |
||||
|
abs_value=abs(val), |
||||
|
is_bh=val < 0, |
||||
|
is_mixed=False, |
||||
|
context_key=None, |
||||
|
_threshold=val, |
||||
|
) |
||||
|
parsed.translations = build_translations_for_parsed(parsed, category=cat) |
||||
|
return parsed |
||||
|
|
||||
|
# 6. Alternatives (2 or 3+ exact numbers separated by ';', e.g. "131;140" or "131;140;142") |
||||
|
if RE_ALT_EXACT.match(canonical): |
||||
|
parts = [int(p) for p in canonical.split(";")] |
||||
|
if is_age and any(p < 0 for p in parts): |
||||
|
return _invalid( |
||||
|
"Negative values are not allowed for age alternatives.", |
||||
|
";".join(str(abs(p)) for p in parts), |
||||
|
) |
||||
|
v_type = "alternatives" if len(parts) == 2 else "alternatives_multi" |
||||
|
parsed = ParsedCanonicalDate( |
||||
|
raw_value=canonical, |
||||
|
is_valid=True, |
||||
|
parse_type=v_type, |
||||
|
value=None, |
||||
|
value_min=min(parts), |
||||
|
value_max=max(parts), |
||||
|
values_list=parts, |
||||
|
abs_value=None, |
||||
|
is_bh=min(parts) < 0, |
||||
|
is_mixed=False, |
||||
|
context_key=None, |
||||
|
_parts=parts, |
||||
|
) |
||||
|
parsed.translations = build_translations_for_parsed(parsed, category=cat) |
||||
|
return parsed |
||||
|
|
||||
|
# 7. Alternatives Range (2 or 3+ ranges separated by ';', e.g. "75..76;68..69") |
||||
|
if RE_ALT_RANGE.match(canonical): |
||||
|
raw_ranges = canonical.split(";") |
||||
|
ranges: List[Tuple[int, int]] = [] |
||||
|
all_nums: List[int] = [] |
||||
|
for r_str in raw_ranges: |
||||
|
a_str, b_str = r_str.split("..") |
||||
|
a, b = int(a_str), int(b_str) |
||||
|
if is_age and (a < 0 or b < 0): |
||||
|
return _invalid("Negative values are not allowed for age ranges.") |
||||
|
if a > b: |
||||
|
return _invalid(f"Range minimum ({a}) cannot be greater than maximum ({b}) in '{r_str}'.") |
||||
|
ranges.append((a, b)) |
||||
|
all_nums.extend([a, b]) |
||||
|
|
||||
|
v_type = "alternatives_range" if len(ranges) == 2 else "alternatives_range_multi" |
||||
|
parsed = ParsedCanonicalDate( |
||||
|
raw_value=canonical, |
||||
|
is_valid=True, |
||||
|
parse_type=v_type, |
||||
|
value=None, |
||||
|
value_min=min(all_nums), |
||||
|
value_max=max(all_nums), |
||||
|
values_list=all_nums, |
||||
|
abs_value=None, |
||||
|
is_bh=min(all_nums) < 0, |
||||
|
is_mixed=False, |
||||
|
context_key=None, |
||||
|
_ranges=ranges, |
||||
|
) |
||||
|
parsed.translations = build_translations_for_parsed(parsed, category=cat) |
||||
|
return parsed |
||||
|
|
||||
|
# 8. Context tag (e.g. "#hijra_habasha") |
||||
|
m_ctx = RE_CONTEXT.match(canonical) |
||||
|
if m_ctx: |
||||
|
if is_age: |
||||
|
return _invalid("Context tags (#key) are only allowed for birth/death years, not for age.") |
||||
|
ctx_key = m_ctx.group(1).lower() |
||||
|
normalized_tag = f"#{ctx_key}" |
||||
|
parsed = ParsedCanonicalDate( |
||||
|
raw_value=normalized_tag, |
||||
|
is_valid=True, |
||||
|
parse_type="context", |
||||
|
value=None, |
||||
|
value_min=None, |
||||
|
value_max=None, |
||||
|
values_list=[], |
||||
|
abs_value=None, |
||||
|
is_bh=False, |
||||
|
is_mixed=False, |
||||
|
context_key=ctx_key, |
||||
|
) |
||||
|
parsed.translations = build_translations_for_parsed(parsed, category=cat) |
||||
|
return parsed |
||||
|
|
||||
|
msg = ( |
||||
|
f"Invalid canonical format '{canonical}'. " |
||||
|
"Allowed formats: exact (143, -5), range (170..193), circa (~100), " |
||||
|
"greater/less (>80, <150), alternatives (131;140), range alternatives (75..76;68..69), " |
||||
|
"or context (#hijra_habasha)." |
||||
|
) |
||||
|
return _invalid(msg) |
||||
|
|
||||
|
|
||||
|
def render_date_from_parsed( |
||||
|
parsed: Any, |
||||
|
date_type: str = "birth", |
||||
|
lang: str = "en", |
||||
|
translations_override: Optional[List[Dict[str, Any]]] = None, |
||||
|
) -> str: |
||||
|
""" |
||||
|
Renders the human-readable localized date/age string for a given parsed canonical object, |
||||
|
`date_type` ('birth', 'death', or 'age'), and language ('ru', 'en', 'ar'). |
||||
|
""" |
||||
|
lang_code = normalize_lang_code(lang) |
||||
|
v_type = parsed["parse_type"] if isinstance(parsed, dict) and "parse_type" in parsed else parsed.get("type") |
||||
|
canonical_str = parsed.get("raw_value") or parsed.get("canonical") or "" |
||||
|
|
||||
|
if translations_override: |
||||
|
for t in translations_override: |
||||
|
if isinstance(t, dict) and t.get("language_code") == lang_code: |
||||
|
txt = t.get("text") or t.get("title") or t.get("context_translation") |
||||
|
if txt: |
||||
|
return str(txt) |
||||
|
|
||||
|
# 1. Context tag |
||||
|
if v_type == "context": |
||||
|
tag_key = canonical_str if canonical_str.startswith("#") else f"#{parsed.get('context_key', '')}" |
||||
|
default_ctx = DEFAULT_CONTEXT_TAGS.get(tag_key, {}) |
||||
|
return default_ctx.get(lang_code, tag_key) |
||||
|
|
||||
|
is_age = (date_type == "age") |
||||
|
|
||||
|
# 2. Multi-item ready strings (3+ alternatives) |
||||
|
if v_type == "alternatives_multi": |
||||
|
parts = [str(p) for p in (parsed.get("_parts") or canonical_to_parts(canonical_str))] |
||||
|
joined = _join_multi_items(parts, lang_code) |
||||
|
if is_age: |
||||
|
age_unit = {"ar": "سنة", "ru": "лет", "en": "years"}[lang_code] |
||||
|
return f"{joined} {age_unit}" |
||||
|
return joined |
||||
|
|
||||
|
if v_type == "alternatives_range_multi": |
||||
|
ranges = parsed.get("_ranges") or canonical_to_ranges(canonical_str) |
||||
|
if is_age: |
||||
|
age_items = [_format_single_range(a, b, lang_code, for_age=True, with_unit=False) for a, b in ranges] |
||||
|
joined_age = _join_multi_items(age_items, lang_code) |
||||
|
age_unit = {"ar": "سنة", "ru": "лет", "en": "years"}[lang_code] |
||||
|
return f"{joined_age} {age_unit}" |
||||
|
hijri_items = [_format_single_range(a, b, lang_code, for_age=False, with_unit=False) for a, b in ranges] |
||||
|
return _join_multi_items(hijri_items, lang_code) |
||||
|
|
||||
|
# 3. Standard template rendering |
||||
|
tpl_tuple = DEFAULT_TEMPLATES.get(v_type, DEFAULT_TEMPLATES["exact"]).get(lang_code) |
||||
|
disp, disp_neg, disp_age = tpl_tuple if tpl_tuple else ("{value}", None, "{value}") |
||||
|
|
||||
|
is_neg_or_mixed = bool(parsed.get("is_bh") or parsed.get("is_mixed")) |
||||
|
if is_age: |
||||
|
template = disp_age |
||||
|
elif is_neg_or_mixed: |
||||
|
template = disp_neg or disp |
||||
|
else: |
||||
|
template = disp |
||||
|
|
||||
|
if not template: |
||||
|
return canonical_str |
||||
|
|
||||
|
# Special case: range where both min and max are negative (e.g. -10..-5) |
||||
|
v_min = parsed.get("value_min") |
||||
|
v_max = parsed.get("value_max") |
||||
|
if v_type == "range" and not is_age and v_min is not None and v_max is not None and v_min < 0 and v_max < 0: |
||||
|
return _format_single_range(v_min, v_max, lang_code, for_age=False, with_unit=True) |
||||
|
|
||||
|
abs_val = parsed.get("abs_value") |
||||
|
if v_type == "greater_than": |
||||
|
threshold = parsed.get("_threshold") |
||||
|
if threshold is None: |
||||
|
threshold = (v_min - 1) if v_min is not None else 0 |
||||
|
sub_min = threshold |
||||
|
sub_abs_min = abs(threshold) |
||||
|
sub_max = v_max |
||||
|
sub_abs_max = abs(v_max) if v_max is not None else None |
||||
|
sub_val = threshold |
||||
|
elif v_type == "less_than": |
||||
|
threshold = parsed.get("_threshold") |
||||
|
if threshold is None: |
||||
|
threshold = (v_max + 1) if v_max is not None else 0 |
||||
|
sub_max = threshold |
||||
|
sub_abs_max = abs(threshold) |
||||
|
sub_min = v_min |
||||
|
sub_abs_min = abs(v_min) if v_min is not None else None |
||||
|
sub_val = threshold |
||||
|
elif v_type == "alternatives": |
||||
|
parts = parsed.get("_parts") or canonical_to_parts(canonical_str) |
||||
|
sub_min = parts[0] if len(parts) > 0 else v_min |
||||
|
sub_max = parts[1] if len(parts) > 1 else v_max |
||||
|
sub_abs_min = abs(sub_min) if sub_min is not None else None |
||||
|
sub_abs_max = abs(sub_max) if sub_max is not None else None |
||||
|
sub_val = sub_min |
||||
|
else: |
||||
|
sub_min = v_min |
||||
|
sub_max = v_max |
||||
|
sub_abs_min = abs(v_min) if v_min is not None else None |
||||
|
sub_abs_max = abs(v_max) if v_max is not None else None |
||||
|
sub_val = v_min if v_min is not None else v_max |
||||
|
|
||||
|
range1_str = "" |
||||
|
range2_str = "" |
||||
|
if v_type == "alternatives_range": |
||||
|
ranges = parsed.get("_ranges") or canonical_to_ranges(canonical_str) |
||||
|
if len(ranges) >= 2: |
||||
|
if is_age: |
||||
|
range1_str = _format_single_range(ranges[0][0], ranges[0][1], lang_code, for_age=True, with_unit=False) |
||||
|
range2_str = _format_single_range(ranges[1][0], ranges[1][1], lang_code, for_age=True, with_unit=False) |
||||
|
else: |
||||
|
range1_str = _format_single_range(ranges[0][0], ranges[0][1], lang_code, for_age=False, with_unit=True) |
||||
|
range2_str = _format_single_range(ranges[1][0], ranges[1][1], lang_code, for_age=False, with_unit=True) |
||||
|
|
||||
|
return template.format( |
||||
|
value=sub_val if sub_val is not None else "", |
||||
|
abs_value=abs_val if abs_val is not None else (abs(sub_val) if sub_val is not None else ""), |
||||
|
min=sub_min if sub_min is not None else "", |
||||
|
max=sub_max if sub_max is not None else "", |
||||
|
abs_min=sub_abs_min if sub_abs_min is not None else "", |
||||
|
abs_max=sub_abs_max if sub_abs_max is not None else "", |
||||
|
range1=range1_str, |
||||
|
range2=range2_str, |
||||
|
) |
||||
|
|
||||
|
|
||||
|
def build_translations_for_parsed( |
||||
|
parsed: Any, |
||||
|
category: str = "birth", |
||||
|
custom_context_translations: Optional[Dict[str, str]] = None, |
||||
|
) -> List[Dict[str, str]]: |
||||
|
""" |
||||
|
Generates the 3 language translation objects (ru, en, ar) in standard |
||||
|
`[{"language_code": "ru", "text": "..."}, ...]` format stored on `YearValue.translations`. |
||||
|
""" |
||||
|
translations: List[Dict[str, str]] = [] |
||||
|
v_type = parsed.get("parse_type") or parsed.get("type") |
||||
|
for lang in SUPPORTED_LANGS: |
||||
|
if v_type == "context" and custom_context_translations and custom_context_translations.get(lang): |
||||
|
txt = custom_context_translations[lang] |
||||
|
else: |
||||
|
txt = render_date_from_parsed(parsed, date_type=category, lang=lang) |
||||
|
translations.append({ |
||||
|
"language_code": lang, |
||||
|
"text": txt, |
||||
|
}) |
||||
|
return translations |
||||
|
|
||||
|
|
||||
|
def format_canonical_date( |
||||
|
raw_value: Any, |
||||
|
category: str = "birth", |
||||
|
lang: str = "ru", |
||||
|
date_type: Optional[str] = None, |
||||
|
) -> Optional[str]: |
||||
|
""" |
||||
|
Formats a canonical date/age string into the requested language ('ru', 'en', 'ar'). |
||||
|
Returns raw string as fallback if unparseable. |
||||
|
""" |
||||
|
if raw_value is None or str(raw_value).strip() == "": |
||||
|
return None |
||||
|
cat = date_type or category or "birth" |
||||
|
raw_str = str(raw_value).strip() |
||||
|
parsed = parse_canonical_date(raw_str, category=cat) |
||||
|
if not parsed.is_valid: |
||||
|
return raw_str |
||||
|
return render_date_from_parsed(parsed, date_type=cat, lang=lang) |
||||
|
|
||||
|
|
||||
|
def format_canonical_for_all_langs( |
||||
|
raw_value: Any, |
||||
|
date_type: str = "birth", |
||||
|
translations_override: Optional[List[Dict[str, Any]]] = None, |
||||
|
) -> Dict[str, str]: |
||||
|
""" |
||||
|
Returns a dictionary of formatted strings for all supported languages: {'ru': ..., 'en': ..., 'ar': ...}. |
||||
|
""" |
||||
|
if raw_value is None or str(raw_value).strip() == "": |
||||
|
return {} |
||||
|
raw_str = str(raw_value).strip() |
||||
|
parsed = parse_canonical_date(raw_str, category=date_type) |
||||
|
if not parsed.is_valid: |
||||
|
return {lang: raw_str for lang in SUPPORTED_LANGS} |
||||
|
return { |
||||
|
lang: render_date_from_parsed(parsed, date_type=date_type, lang=lang, translations_override=translations_override) |
||||
|
for lang in SUPPORTED_LANGS |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue