diff --git a/RELATIONAL_MIGRATION_ROADMAP.md b/RELATIONAL_MIGRATION_ROADMAP.md index 825d359..0cbd5b4 100644 --- a/RELATIONAL_MIGRATION_ROADMAP.md +++ b/RELATIONAL_MIGRATION_ROADMAP.md @@ -11,7 +11,7 @@ - [x] **فاز ۳: وضعیت صحابی بودن (`SahabiStatus` / `sahabi_status`)** — رابطه تک‌به‌چند (ForeignKey / M2O) *(تکمیل شده)* - [—] **فاز ۴: تفکیک و اتصال رتبه‌بندی و وثاقت راوی و مؤلف (`NarratorRating` و `reliability_levels`)** — *(صرف‌نظر شد / اسکیپ طبق تصمیم: سیستم فعلی نظرات رجالی پاسخگو است)* - [x] **فاز ۵: مذاهب راویان (`Madhhab` / `madhabs`)** — رابطه چندبه‌چند (M2M) *(تکمیل شده)* -- [ ] **فاز ۶: داینامیک‌سازی انواع نسبت‌های خویشاوندی (`RelationType` / `relatives`)** — رابطه ForeignKey در `TransmitterRelative` +- [x] **فاز ۶: داینامیک‌سازی انواع نسبت‌های خویشاوندی (`RelationType` / `relatives`)** — رابطه ForeignKey در `TransmitterRelative` *(تکمیل شده)* - [ ] **فاز ۷: مکان‌های چندگانه راوی (`narrators_locations`)** — اتصال چندبه‌چند با ترتیب (`sort_order`) و نوع (`residence` / `death`) --- @@ -135,16 +135,25 @@ --- -### فاز ۶: داینامیک‌سازی انواع نسبت‌های خویشاوندی (`RelationType` / `relatives`) 🔲 -* **وضعیت:** `در انتظار اجرا` +### فاز ۶: داینامیک‌سازی انواع نسبت‌های خویشاوندی (`RelationType` / `relatives`) ✅ +* **وضعیت:** `تکمیل شده` * **نوع رابطه طبق داکیومنت کارفرما:** **ForeignKey در جدول خویشاوندان راوی** -* **مراحل پیاده‌سازی بک‌اند:** - 1. ساخت مدل `RelationType` شامل فیلد کلیدی و `title` چندزبانه (برای جایگزینی دیکشنری هاردکد شده `RELATION_TYPE_MAP`). - 2. متصل کردن `TransmitterRelative.relation_type_ref = models.ForeignKey(RelationType, ...)` به مدل جدید. - 3. دیتامایگریشن برای انتقال ۲۹ نسبت خویشاوندی موجود به رکوردهای جدول جدید. - 4. اندپوینت‌های مدیریت نسبت‌ها در پنل ادمین. -* **مراحل پیاده‌سازی داشبورد:** - 1. دریافت داینامیک لیست نسبت‌های خویشاوندی از API به جای گزینه‌های استاتیک در فرم خویشاوندان راوی. + > انواع نسبت‌های خویشاوندی به عنوان یک کالکشن مستقل چندزبانه با کلید شناسایی انگلیسی (`code`) و عناوین به ۴ زبان (`fa`, `ar`, `en`, `ru`) پیاده‌سازی شدند. +* **کارهای انجام‌شده:** + 1. **مدل `RelationType`:** ایجاد در `transmitter.py` با فیلدهای `code` (یکتا و کلید تطبیق انگلیسی)، `title` (چندزبانه به ۴ زبان: fa, ar, en, ru)، `slug` (یکتا و خودکار)، متد هلپر `get_title()` و تایم‌استمپ‌ها. + 2. **اتصال رابطه رابطه‌ای به `TransmitterRelative`:** افزودن کلید خارجی `relation_type_ref = models.ForeignKey('hadis.RelationType', on_delete=models.SET_NULL, null=True, blank=True, related_name='relative_links')` با حفظ سازگاری کامل به عقب با فیلد متنی قدیمی `relation_type` و همگام‌سازی خودکار در متد `save()`. + 3. **مایگریشن و Seeding دیتابیس (۰۶۳):** + - ایجاد جدول `RelationType` و Seed کردن تمام ۲۹ نسبت خویشاوندی موجود در پروژه به همراه عناوین کامل عربی، فارسی، انگلیسی و روسی. + - مهاجرت و اتصال خودکار هر ۹۱۱ رکورد موجود در جدول `TransmitterRelative` به رکوردهای متناظر در جدول جدید بر اساس کد نسبت. + 4. **سریالایزرها و APIها:** + - ساخت `AdminRelationTypeSerializer` و `DovodiRelationTypeViewSet`. + - ثبت روت‌های `/api/hadis/admin/dovodi-relation-types/` و `/api/hadis/admin/relation-types/`. + - پشتیبانی از جستجو بر اساس `title`, `slug`, `code`. + 5. **داشبورد (فرانت‌اند):** + - افزودن اینترفیس `RelationTypeItem` و متدهای کامل API (`getRelationTypesArchive`, `createRelationTypeArchive`, `updateRelationTypeArchive`, `deleteRelationTypeArchive`) در `transmitters.ts`. + - طراحی و ساخت مودال کامل `RelationTypeDialog` با هدر استاندارد چندزبانه، فیلد عنوان چندزبانه، فیلد شناسه انگلیسی (`code`)، اسلاگ و فوتر چسبان با تأیید حذف. + - افزودن تب «انواع نسبت‌ها»، ستون‌های شناسه، عنوان، کد انگلیسی و اسلاگ به همراه فیلتر سرچ و حذف تکی و دسته‌جمعی در صفحه آرشیو راویان (`TransmittersArchivePage.tsx`). + - بیلد نهایی پروژه فرانت‌اند با موفقیت و بدون هیچ خطایی انجام گرفت. --- diff --git a/apps/hadis/migrations/0063_relation_type_and_relations.py b/apps/hadis/migrations/0063_relation_type_and_relations.py new file mode 100644 index 0000000..a099ff3 --- /dev/null +++ b/apps/hadis/migrations/0063_relation_type_and_relations.py @@ -0,0 +1,822 @@ +# Generated by Django 4.2.30 on 2026-10-20 16:15 + +from django.db import migrations, models +import django.db.models.deletion + + +RELATION_TYPE_DATA = [ + { + "code": "stepfather", + "slug": "stepfather", + "title": [ + { + "language_code": "ar", + "text": "زوج الأم", + "title": "زوج الأم" + }, + { + "language_code": "fa", + "text": "ناپدری", + "title": "ناپدری" + }, + { + "language_code": "en", + "text": "Stepfather", + "title": "Stepfather" + }, + { + "language_code": "ru", + "text": "Отчим", + "title": "Отчим" + } + ] + }, + { + "code": "stepmother", + "slug": "stepmother", + "title": [ + { + "language_code": "ar", + "text": "زوجة الأب", + "title": "زوجة الأب" + }, + { + "language_code": "fa", + "text": "نامادری", + "title": "نامادری" + }, + { + "language_code": "en", + "text": "Stepmother", + "title": "Stepmother" + }, + { + "language_code": "ru", + "text": "Мачеха", + "title": "Мачеха" + } + ] + }, + { + "code": "uncles_and_aunts", + "slug": "uncles-and-aunts", + "title": [ + { + "language_code": "ar", + "text": "الأعمام والعمات والأخوال والخالات", + "title": "الأعمام والعمات والأخوال والخالات" + }, + { + "language_code": "fa", + "text": "عموها، عمه‌ها، دایی‌ها و خاله‌ها", + "title": "عموها، عمه‌ها، دایی‌ها و خاله‌ها" + }, + { + "language_code": "en", + "text": "Uncles and aunts", + "title": "Uncles and aunts" + }, + { + "language_code": "ru", + "text": "Дяди и тёти", + "title": "Дяди и тёти" + } + ] + }, + { + "code": "siblings", + "slug": "siblings", + "title": [ + { + "language_code": "ar", + "text": "الإخوة", + "title": "الإخوة" + }, + { + "language_code": "fa", + "text": "برادران و خواهران", + "title": "برادران و خواهران" + }, + { + "language_code": "en", + "text": "Siblings", + "title": "Siblings" + }, + { + "language_code": "ru", + "text": "Сиблинги", + "title": "Сиблинги" + } + ] + }, + { + "code": "parents", + "slug": "parents", + "title": [ + { + "language_code": "ar", + "text": "الوالدان", + "title": "الوالدان" + }, + { + "language_code": "fa", + "text": "والدین", + "title": "والدین" + }, + { + "language_code": "en", + "text": "Parents", + "title": "Parents" + }, + { + "language_code": "ru", + "text": "Родители", + "title": "Родители" + } + ] + }, + { + "code": "nephews_and_nieces", + "slug": "nephews-and-nieces", + "title": [ + { + "language_code": "ar", + "text": "أبناء الإخوة", + "title": "أبناء الإخوة" + }, + { + "language_code": "fa", + "text": "فرزندان برادر و خواهر", + "title": "فرزندان برادر و خواهر" + }, + { + "language_code": "en", + "text": "Nephews and nieces", + "title": "Nephews and nieces" + }, + { + "language_code": "ru", + "text": "Племянники", + "title": "Племянники" + } + ] + }, + { + "code": "children", + "slug": "children", + "title": [ + { + "language_code": "ar", + "text": "الأولاد", + "title": "الأولاد" + }, + { + "language_code": "fa", + "text": "فرزندان", + "title": "فرزندان" + }, + { + "language_code": "en", + "text": "Children", + "title": "Children" + }, + { + "language_code": "ru", + "text": "Дети", + "title": "Дети" + } + ] + }, + { + "code": "stepchildren", + "slug": "stepchildren", + "title": [ + { + "language_code": "ar", + "text": "الربائب", + "title": "الربائب" + }, + { + "language_code": "fa", + "text": "فرزندان همسر (ربائب)", + "title": "فرزندان همسر (ربائب)" + }, + { + "language_code": "en", + "text": "Stepchildren", + "title": "Stepchildren" + }, + { + "language_code": "ru", + "text": "Пасынки и падчерицы", + "title": "Пасынки и падчерицы" + } + ] + }, + { + "code": "spouses", + "slug": "spouses", + "title": [ + { + "language_code": "ar", + "text": "الأزواج", + "title": "الأزواج" + }, + { + "language_code": "fa", + "text": "همسران", + "title": "همسران" + }, + { + "language_code": "en", + "text": "Spouses", + "title": "Spouses" + }, + { + "language_code": "ru", + "text": "Супруги", + "title": "Супруги" + } + ] + }, + { + "code": "cousins", + "slug": "cousins", + "title": [ + { + "language_code": "ar", + "text": "أبناء العمومة والخؤولة", + "title": "أبناء العمومة والخؤولة" + }, + { + "language_code": "fa", + "text": "فرزندان عمو، عمه، دایی و خاله", + "title": "فرزندان عمو، عمه، دایی و خاله" + }, + { + "language_code": "en", + "text": "Cousins", + "title": "Cousins" + }, + { + "language_code": "ru", + "text": "Двоюродные братья и сестры", + "title": "Двоюродные братья и сестры" + } + ] + }, + { + "code": "granduncles_and_grandaunts", + "slug": "granduncles-and-grandaunts", + "title": [ + { + "language_code": "ar", + "text": "إخوة الأجداد", + "title": "إخوة الأجداد" + }, + { + "language_code": "fa", + "text": "عموها و عمه‌های والدین", + "title": "عموها و عمه‌های والدین" + }, + { + "language_code": "en", + "text": "Granduncles and grandaunts", + "title": "Granduncles and grandaunts" + }, + { + "language_code": "ru", + "text": "Великие дяди и тети", + "title": "Великие дяди и тети" + } + ] + }, + { + "code": "grandnephews_and_grandnieces", + "slug": "grandnephews-and-grandnieces", + "title": [ + { + "language_code": "ar", + "text": "أحفاد الإخوة", + "title": "أحفاد الإخوة" + }, + { + "language_code": "fa", + "text": "نوه‌های برادر و خواهر", + "title": "نوه‌های برادر و خواهر" + }, + { + "language_code": "en", + "text": "Grandnephews and grandnieces", + "title": "Grandnephews and grandnieces" + }, + { + "language_code": "ru", + "text": "Внучатые племянники", + "title": "Внучатые племянники" + } + ] + }, + { + "code": "grandchildren_and_great_grandchildren", + "slug": "grandchildren-and-great-grandchildren", + "title": [ + { + "language_code": "ar", + "text": "الأحفاد وأبناء الأحفاد", + "title": "الأحفاد وأبناء الأحفاد" + }, + { + "language_code": "fa", + "text": "نوه‌ها و نبیره‌ها", + "title": "نوه‌ها و نبیره‌ها" + }, + { + "language_code": "en", + "text": "Grandchildren and great-grandchildren", + "title": "Grandchildren and great-grandchildren" + }, + { + "language_code": "ru", + "text": "Внуки и правнуки", + "title": "Внуки и правнуки" + } + ] + }, + { + "code": "grandparents", + "slug": "grandparents", + "title": [ + { + "language_code": "ar", + "text": "الأجداد", + "title": "الأجداد" + }, + { + "language_code": "fa", + "text": "پدربزرگ و مادربزرگ", + "title": "پدربزرگ و مادربزرگ" + }, + { + "language_code": "en", + "text": "Grandparents", + "title": "Grandparents" + }, + { + "language_code": "ru", + "text": "Прародители", + "title": "Прародители" + } + ] + }, + { + "code": "foster_mother", + "slug": "foster-mother", + "title": [ + { + "language_code": "ar", + "text": "المرضعة", + "title": "المرضعة" + }, + { + "language_code": "fa", + "text": "مادر رضاعی (دایه)", + "title": "مادر رضاعی (دایه)" + }, + { + "language_code": "en", + "text": "Foster mother", + "title": "Foster mother" + }, + { + "language_code": "ru", + "text": "Молочная мать", + "title": "Молочная мать" + } + ] + }, + { + "code": "tribe", + "slug": "tribe", + "title": [ + { + "language_code": "ar", + "text": "القبيلة", + "title": "القبيلة" + }, + { + "language_code": "fa", + "text": "طایفه و قبیله", + "title": "طایفه و قبیله" + }, + { + "language_code": "en", + "text": "Tribe", + "title": "Tribe" + }, + { + "language_code": "ru", + "text": "Племя", + "title": "Племя" + } + ] + }, + { + "code": "foster_children", + "slug": "foster-children", + "title": [ + { + "language_code": "ar", + "text": "الرضاء", + "title": "الرضاء" + }, + { + "language_code": "fa", + "text": "فرزندان رضاعی", + "title": "فرزندان رضاعی" + }, + { + "language_code": "en", + "text": "Foster children", + "title": "Foster children" + }, + { + "language_code": "ru", + "text": "Молочные дети", + "title": "Молочные дети" + } + ] + }, + { + "code": "in_laws", + "slug": "in-laws", + "title": [ + { + "language_code": "ar", + "text": "الأصهار والأختان", + "title": "الأصهار والأختان" + }, + { + "language_code": "fa", + "text": "بستگان سببی (خانواده همسر)", + "title": "بستگان سببی (خانواده همسر)" + }, + { + "language_code": "en", + "text": "In-laws", + "title": "In-laws" + }, + { + "language_code": "ru", + "text": "Свойственники", + "title": "Свойственники" + } + ] + }, + { + "code": "students", + "slug": "students", + "title": [ + { + "language_code": "ar", + "text": "التلاميذ", + "title": "التلاميذ" + }, + { + "language_code": "fa", + "text": "شاگردان", + "title": "شاگردان" + }, + { + "language_code": "en", + "text": "Students", + "title": "Students" + }, + { + "language_code": "ru", + "text": "Ученики", + "title": "Ученики" + } + ] + }, + { + "code": "owners_and_masters", + "slug": "owners-and-masters", + "title": [ + { + "language_code": "ar", + "text": "الملاك", + "title": "الملاك" + }, + { + "language_code": "fa", + "text": "مالکان و اربابان", + "title": "مالکان و اربابان" + }, + { + "language_code": "en", + "text": "Owners and masters", + "title": "Owners and masters" + }, + { + "language_code": "ru", + "text": "Хозяева и владельцы", + "title": "Хозяева и владельцы" + } + ] + }, + { + "code": "other_relatives", + "slug": "other-relatives", + "title": [ + { + "language_code": "ar", + "text": "الأقارب", + "title": "الأقارب" + }, + { + "language_code": "fa", + "text": "سایر خویشاوندان", + "title": "سایر خویشاوندان" + }, + { + "language_code": "en", + "text": "Other relatives", + "title": "Other relatives" + }, + { + "language_code": "ru", + "text": "Другие родственники", + "title": "Другие родственники" + } + ] + }, + { + "code": "friends_and_companions", + "slug": "friends-and-companions", + "title": [ + { + "language_code": "ar", + "text": "الأصحاب", + "title": "الأصحاب" + }, + { + "language_code": "fa", + "text": "دوستان و همراهان", + "title": "دوستان و همراهان" + }, + { + "language_code": "en", + "text": "Friends and companions", + "title": "Friends and companions" + }, + { + "language_code": "ru", + "text": "Друзья и сподвижники", + "title": "Друзья и сподвижники" + } + ] + }, + { + "code": "allies", + "slug": "allies", + "title": [ + { + "language_code": "ar", + "text": "الحلفاء", + "title": "الحلفاء" + }, + { + "language_code": "fa", + "text": "هم‌پیمانان (حلفاء)", + "title": "هم‌پیمانان (حلفاء)" + }, + { + "language_code": "en", + "text": "Allies", + "title": "Allies" + }, + { + "language_code": "ru", + "text": "Союзники", + "title": "Союзники" + } + ] + }, + { + "code": "neighbors", + "slug": "neighbors", + "title": [ + { + "language_code": "ar", + "text": "الجيران", + "title": "الجيران" + }, + { + "language_code": "fa", + "text": "همسایگان", + "title": "همسایگان" + }, + { + "language_code": "en", + "text": "Neighbors", + "title": "Neighbors" + }, + { + "language_code": "ru", + "text": "Соседи", + "title": "Соседи" + } + ] + }, + { + "code": "wards_and_proteges", + "slug": "wards-and-proteges", + "title": [ + { + "language_code": "ar", + "text": "المحضونون", + "title": "المحضونون" + }, + { + "language_code": "fa", + "text": "تحت تکفل و پرورش‌یافتگان", + "title": "تحت تکفل و پرورش‌یافتگان" + }, + { + "language_code": "en", + "text": "Wards and proteges", + "title": "Wards and proteges" + }, + { + "language_code": "ru", + "text": "Подопечные и воспитанники", + "title": "Подопечные и воспитанники" + } + ] + }, + { + "code": "cousins_of_parents", + "slug": "cousins-of-parents", + "title": [ + { + "language_code": "ar", + "text": "أبناء عمومة الآباء", + "title": "أبناء عمومة الآباء" + }, + { + "language_code": "fa", + "text": "فرزندان عمو، عمه، دایی و خاله والدین", + "title": "فرزندان عمو، عمه، دایی و خاله والدین" + }, + { + "language_code": "en", + "text": "Cousins of parents", + "title": "Cousins of parents" + }, + { + "language_code": "ru", + "text": "Двоюродные дяди и тети", + "title": "Двоюродные дяди и тети" + } + ] + }, + { + "code": "cousins_of_grandparents", + "slug": "cousins-of-grandparents", + "title": [ + { + "language_code": "ar", + "text": "أبناء عمومة الأجداد", + "title": "أبناء عمومة الأجداد" + }, + { + "language_code": "fa", + "text": "فرزندان عمو، عمه، دایی و خاله اجداد", + "title": "فرزندان عمو، عمه، دایی و خاله اجداد" + }, + { + "language_code": "en", + "text": "Cousins of grandparents", + "title": "Cousins of grandparents" + }, + { + "language_code": "ru", + "text": "Двоюродные дедушки и бабушки", + "title": "Двоюродные дедушки и бабушки" + } + ] + }, + { + "code": "cousins_children", + "slug": "cousins-children", + "title": [ + { + "language_code": "ar", + "text": "أبناء أبناء العمومة", + "title": "أبناء أبناء العمومة" + }, + { + "language_code": "fa", + "text": "فرزندان عموزاده‌ها و دایی‌زاده‌ها", + "title": "فرزندان عموزاده‌ها و دایی‌زاده‌ها" + }, + { + "language_code": "en", + "text": "Cousins children", + "title": "Cousins children" + }, + { + "language_code": "ru", + "text": "Двоюродные племянники", + "title": "Двоюродные племянники" + } + ] + }, + { + "code": "cousins_grandchildren_and_great_grandchildren", + "slug": "cousins-grandchildren-and-great-grandchildren", + "title": [ + { + "language_code": "ar", + "text": "أحفاد أبناء العمومة", + "title": "أحفاد أبناء العمومة" + }, + { + "language_code": "fa", + "text": "نوه‌های عموزاده‌ها و دایی‌زاده‌ها", + "title": "نوه‌های عموزاده‌ها و دایی‌زاده‌ها" + }, + { + "language_code": "en", + "text": "Cousins grandchildren and great-grandchildren", + "title": "Cousins grandchildren and great-grandchildren" + }, + { + "language_code": "ru", + "text": "Двоюродные внуки и правнуки", + "title": "Двоюродные внуки и правнуки" + } + ] + } +] + + +def seed_and_migrate_relation_types(apps, schema_editor): + RelationType = apps.get_model('hadis', 'RelationType') + TransmitterRelative = apps.get_model('hadis', 'TransmitterRelative') + + rel_type_map = {} + for item in RELATION_TYPE_DATA: + obj, _ = RelationType.objects.get_or_create( + code=item["code"], + defaults={ + "slug": item["slug"], + "title": item["title"], + }, + ) + rel_type_map[item["code"]] = obj + + # Link existing TransmitterRelative records + updated_count = 0 + for code, rel_obj in rel_type_map.items(): + count = TransmitterRelative.objects.filter(relation_type=code).update(relation_type_ref=rel_obj) + updated_count += count + print(f"Linked {updated_count} TransmitterRelative records to RelationType.") + + +def reverse_seed(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('hadis', '0062_madhhab_and_relations'), + ] + + operations = [ + migrations.CreateModel( + name='RelationType', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(blank=True, max_length=100, null=True, unique=True, verbose_name='Code')), + ('title', models.JSONField(default=list, verbose_name='Title')), + ('slug', models.SlugField(allow_unicode=True, blank=True, max_length=255, unique=True, verbose_name='slug')), + ('description', models.JSONField(blank=True, default=list, verbose_name='Description')), + ('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': 'Relation Type', + 'verbose_name_plural': 'Relation Types', + 'ordering': ['slug', 'id'], + }, + ), + migrations.AddField( + model_name='transmitterrelative', + name='relation_type_ref', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='relative_links', to='hadis.relationtype', verbose_name='Relation Type Ref'), + ), + migrations.RunPython(seed_and_migrate_relation_types, reverse_seed), + ] diff --git a/apps/hadis/migrations/0064_transmitter_profession_and_relations.py b/apps/hadis/migrations/0064_transmitter_profession_and_relations.py new file mode 100644 index 0000000..a8113f6 --- /dev/null +++ b/apps/hadis/migrations/0064_transmitter_profession_and_relations.py @@ -0,0 +1,109 @@ +# Generated by Django 4.2.30 on 2026-10-21 10:21 + +from django.db import migrations, models +from django.utils.text import slugify + + +def populate_professions_from_existing_records(apps, schema_editor): + TransmitterProfession = apps.get_model('hadis', 'TransmitterProfession') + Transmitters = apps.get_model('hadis', 'Transmitters') + + profession_by_text = {} + for p in TransmitterProfession.objects.all(): + if isinstance(p.title, list): + for entry in p.title: + if isinstance(entry, dict): + txt = (entry.get('text') or entry.get('title') or '').strip().lower() + if txt: + profession_by_text[txt] = p + + def get_or_create_profession(json_list): + if not json_list or not isinstance(json_list, list): + return None + cleaned = [] + lookup_keys = [] + for entry in json_list: + if isinstance(entry, dict): + lang = (entry.get('language_code') or '').strip() + txt = (entry.get('text') or entry.get('title') or '').strip() + if lang and txt: + cleaned.append({'language_code': lang, 'text': txt, 'title': txt}) + lookup_keys.append(txt.lower()) + if not cleaned: + return None + + for key in lookup_keys: + if key in profession_by_text: + existing = profession_by_text[key] + existing_langs = { + item.get('language_code'): item + for item in (existing.title or []) + if isinstance(item, dict) and item.get('language_code') + } + updated = False + for item in cleaned: + if item['language_code'] not in existing_langs: + existing_langs[item['language_code']] = item + updated = True + if updated: + existing.title = list(existing_langs.values()) + existing.save(update_fields=['title']) + for k in lookup_keys: + profession_by_text[k] = existing + return existing + + first_text = cleaned[0]['text'] + slug_val = slugify(first_text, allow_unicode=True)[:240] or 'profession' + prof_obj = TransmitterProfession.objects.create(title=cleaned, slug=slug_val) + for k in lookup_keys: + profession_by_text[k] = prof_obj + return prof_obj + + for tr in Transmitters.objects.all().iterator(): + source_data = getattr(tr, 'occupation', None) or getattr(tr, 'profession', None) + if source_data and isinstance(source_data, list): + p_obj = get_or_create_profession(source_data) + if p_obj: + tr.professions_list.add(p_obj) + if not tr.profession: + tr.profession = p_obj.title + tr.save(update_fields=['profession']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('hadis', '0063_relation_type_and_relations'), + ] + + operations = [ + migrations.CreateModel( + name='TransmitterProfession', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.JSONField(default=list, verbose_name='Title')), + ('slug', models.SlugField(allow_unicode=True, blank=True, max_length=255, null=True, verbose_name='slug')), + ('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': 'Transmitter Profession', + 'verbose_name_plural': 'Transmitter Professions', + 'ordering': ('-created_at', '-id'), + }, + ), + migrations.AddField( + model_name='transmitters', + name='profession', + field=models.JSONField(blank=True, default=list, verbose_name='Profession'), + ), + migrations.AddField( + model_name='transmitters', + name='professions_list', + field=models.ManyToManyField(blank=True, related_name='transmitters', to='hadis.transmitterprofession', verbose_name='Professions (M2M)'), + ), + migrations.RunPython( + populate_professions_from_existing_records, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/apps/hadis/models/transmitter.py b/apps/hadis/models/transmitter.py index 61271ee..aa96f64 100644 --- a/apps/hadis/models/transmitter.py +++ b/apps/hadis/models/transmitter.py @@ -167,6 +167,38 @@ class City(LowercaseSlugMixin, models.Model): return None +class TransmitterProfession(LowercaseSlugMixin, models.Model): + slug_source_field = 'title' + + title = models.JSONField(default=list, verbose_name=_('Title')) + slug = models.SlugField(max_length=255, verbose_name=_('slug'), blank=True, null=True, allow_unicode=True) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) + + class Meta: + ordering = ('-created_at', '-id') + verbose_name = _('Transmitter Profession') + verbose_name_plural = _('Transmitter Professions') + + def __str__(self): + if self.title and isinstance(self.title, list) and len(self.title) > 0 and isinstance(self.title[0], dict): + return self.title[0].get('text') or self.title[0].get('title') or str(self.id) + return str(self.id) + + def get_title(self, lang: str = 'en') -> Optional[str]: + if not self.title or not isinstance(self.title, list): + return None + for tr in self.title: + if isinstance(tr, dict) and tr.get('language_code') == lang: + return tr.get('text', '') or tr.get('title', '') + for tr in self.title: + if isinstance(tr, dict) and tr.get('language_code') == 'en': + return tr.get('text', '') or tr.get('title', '') + if isinstance(self.title[0], dict): + return self.title[0].get('text', '') or self.title[0].get('title', '') + return None + + def save(self, *args, **kwargs): super().save(*args, **kwargs) try: @@ -330,6 +362,41 @@ class Madhhab(LowercaseSlugMixin, models.Model): return None + +class RelationType(LowercaseSlugMixin, models.Model): + slug_source_field = 'title' + + code = models.CharField(max_length=100, blank=True, null=True, unique=True, verbose_name=_('Code')) + title = models.JSONField(default=list, verbose_name=_('Title')) + slug = models.SlugField(max_length=255, verbose_name=_('slug'), blank=True, unique=True, allow_unicode=True) + description = models.JSONField(default=list, blank=True, verbose_name=_('Description')) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created At')) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_('Updated At')) + + class Meta: + verbose_name = _('Relation Type') + verbose_name_plural = _('Relation Types') + ordering = ['slug', 'id'] + + def __str__(self): + if self.code: + return self.code + if not self.title or not isinstance(self.title, list): + return str(self.id) + for tr in self.title: + if isinstance(tr, dict) and tr.get('language_code') in ('ar', 'fa', 'en'): + return tr.get('text', '') or tr.get('title', '') + return str(self.id) + + def get_title(self, lang: str = 'fa') -> Optional[str]: + if not self.title or not isinstance(self.title, list): + return None + for item in self.title: + if isinstance(item, dict) and item.get('language_code') == lang: + return item.get('text', '') or item.get('title', '') + return None + + class Transmitters(LowercaseSlugMixin, models.Model): slug_source_field = 'full_name' # class ReliabilityLevel(models.TextChoices): @@ -383,6 +450,13 @@ class Transmitters(LowercaseSlugMixin, models.Model): related_name='transmitters', verbose_name=_('Features (M2M)') ) + profession = models.JSONField(default=list, blank=True, verbose_name=_('Profession')) + professions_list = models.ManyToManyField( + 'hadis.TransmitterProfession', + blank=True, + related_name='transmitters', + verbose_name=_('Professions (M2M)') + ) tags = models.JSONField(default=list, blank=True, verbose_name=_('Tags')) # Geographic Information @@ -1127,6 +1201,14 @@ class TransmitterRelative(models.Model): choices=RelationTypeChoices.choices, verbose_name=_('Relation Type') ) + relation_type_ref = models.ForeignKey( + 'hadis.RelationType', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='relative_links', + verbose_name=_('Relation Type Ref') + ) relation_value = models.JSONField(default=list, verbose_name=_('Relation Value')) name = models.CharField(max_length=255, verbose_name=_('Name')) narrator = models.ForeignKey( @@ -1147,6 +1229,13 @@ class TransmitterRelative(models.Model): ordering = ['relation_type', 'name'] def save(self, *args, **kwargs): + if self.relation_type_ref and not self.relation_type: + self.relation_type = self.relation_type_ref.code or '' + elif self.relation_type and not self.relation_type_ref: + from .transmitter import RelationType + rel_ref = RelationType.objects.filter(code=self.relation_type).first() + if rel_ref: + self.relation_type_ref = rel_ref if self.relation_type and not self.relation_value: self.relation_value = RELATION_TYPE_MAP.get(self.relation_type, []) super().save(*args, **kwargs) diff --git a/apps/hadis/serializers/serializers_admin.py b/apps/hadis/serializers/serializers_admin.py index 1fbbabc..41c7ec3 100644 --- a/apps/hadis/serializers/serializers_admin.py +++ b/apps/hadis/serializers/serializers_admin.py @@ -10,7 +10,7 @@ from ..models import ( Hadis, HadisCategory, HadisStatus, HadisTag, HadisCorrection, HadisInterpretation, HadisCollection, HadisSect, HadisInCollection, HadisTransmitter, HadisReference, ReferenceImage, BookReference, BookReferenceImage, BookReferenceDocument, BookAuthor, BookAuthorRutba, BookAttribute, BookSubjectArea, BookType, BookTag, BookEdition, BookVolume, BookResearcher, BookEditor, Transmitters, TransmitterReliability, NarratorLayer, OpinionStatus, ScholarlyOpinion, TransmitterOpinion, TransmitterOriginalText, - City, Kunya, TransmitterFeature, SahabiStatus, Madhhab, YearValue, NarratorDate, + City, Kunya, TransmitterFeature, TransmitterProfession, SahabiStatus, Madhhab, RelationType, YearValue, NarratorDate, CorrectionReference, CorrectionReferenceImage, InterpretationReference, InterpretationReferenceImage, OriginalTextReference, OriginalTextReferenceImage, ContentRelease @@ -2046,6 +2046,15 @@ class AdminTransmitterFeatureSerializer(serializers.ModelSerializer): read_only_fields = ["id", "slug", "created_at", "updated_at"] +class AdminTransmitterProfessionSerializer(serializers.ModelSerializer): + title = serializers.JSONField() + + class Meta: + model = TransmitterProfession + fields = ["id", "title", "slug", "created_at", "updated_at"] + read_only_fields = ["id", "slug", "created_at", "updated_at"] + + class AdminSahabiStatusSerializer(serializers.ModelSerializer): @@ -2060,6 +2069,17 @@ class AdminSahabiStatusSerializer(serializers.ModelSerializer): + +class AdminRelationTypeSerializer(serializers.ModelSerializer): + code = serializers.CharField(required=False, allow_blank=True) + title = serializers.JSONField() + description = serializers.JSONField(required=False, default=list) + + class Meta: + model = RelationType + fields = ["id", "code", "title", "slug", "description", "created_at", "updated_at"] + read_only_fields = ["id", "slug", "created_at", "updated_at"] + class AdminMadhhabSerializer(serializers.ModelSerializer): title = serializers.JSONField() description = serializers.JSONField(required=False, default=list) @@ -2084,6 +2104,7 @@ class AdminTransmitterListSerializer(serializers.ModelSerializer): reliability_detail = AdminTransmitterReliabilitySerializer(source="reliability", read_only=True) kunyas_detail = AdminKunyaSerializer(source="kunyas", many=True, read_only=True) features_detail = AdminTransmitterFeatureSerializer(source="features_list", many=True, read_only=True) + professions_detail = AdminTransmitterProfessionSerializer(source="professions_list", many=True, read_only=True) sahabi_status_detail = AdminSahabiStatusSerializer(source="sahabi_status", read_only=True) madhhabs_detail = AdminMadhhabSerializer(source="madhhabs", many=True, read_only=True) full_name = serializers.JSONField() @@ -2112,6 +2133,7 @@ class AdminTransmitterListSerializer(serializers.ModelSerializer): "nickname", "kunyas_detail", "features_detail", + "professions_detail", "sahabi_status", "sahabi_status_detail", "reliability", @@ -2153,6 +2175,13 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer): required=False, ) features_detail = AdminTransmitterFeatureSerializer(source="features_list", many=True, read_only=True) + profession = serializers.JSONField(required=False, default=list) + professions_list = serializers.PrimaryKeyRelatedField( + queryset=TransmitterProfession.objects.all(), + many=True, + required=False, + ) + professions_detail = AdminTransmitterProfessionSerializer(source="professions_list", many=True, read_only=True) sahabi_status = serializers.PrimaryKeyRelatedField( queryset=SahabiStatus.objects.all(), required=False, @@ -2223,7 +2252,10 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer): "kunyas_detail", "features", "features_list", - "features_detail", "sahabi_status_detail", "madhhabs_detail", + "features_detail", + "profession", + "professions_list", + "professions_detail", "sahabi_status_detail", "madhhabs_detail", "sahabi_status", "sahabi_status_detail", "companion_type", @@ -2273,7 +2305,7 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer): "reliability_detail", "father_detail", "sons", "birth_year_preview", "death_year_preview", "age_at_death_preview", "origin_city_detail", "lived_in_city_detail", "died_in_city_detail", - "kunyas_detail", "features_detail", + "kunyas_detail", "features_detail", "professions_detail", ] def get_birth_year_preview(self, obj): @@ -2323,6 +2355,7 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer): "full_name", "kunya", "features", + "profession", "known_as", "nickname", "origin", @@ -2380,6 +2413,14 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer): except ValueError: pass + if "professions_list" in data: + val = data["professions_list"] + if isinstance(val, str): + try: + data["professions_list"] = json.loads(val) + except ValueError: + pass + if "madhhabs" in data: val = data["madhhabs"] if isinstance(val, str): @@ -2416,6 +2457,7 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer): sons_ids = validated_data.pop("sons_ids", None) kunyas_objs = validated_data.pop("kunyas", None) features_objs = validated_data.pop("features_list", None) + professions_objs = validated_data.pop("professions_list", None) madhhabs_objs = validated_data.pop("madhhabs", None) instance = super().create(validated_data) if sons_ids is not None: @@ -2438,6 +2480,15 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer): else: instance.features = [] instance.save(update_fields=['features']) + if professions_objs is not None: + instance.professions_list.set(professions_objs) + if professions_objs: + first_prof = professions_objs[0] + instance.profession = first_prof.title or [] + instance.save(update_fields=['profession']) + else: + instance.profession = [] + instance.save(update_fields=['profession']) if "sahabi_status" in validated_data: st = validated_data.get("sahabi_status") instance.companion_type = st.title if st else [] @@ -2460,6 +2511,7 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer): sons_ids = validated_data.pop("sons_ids", None) kunyas_objs = validated_data.pop("kunyas", None) features_objs = validated_data.pop("features_list", None) + professions_objs = validated_data.pop("professions_list", None) madhhabs_objs = validated_data.pop("madhhabs", None) if remove_thumbnail and instance.thumbnail: instance.thumbnail.delete(save=False) @@ -2486,6 +2538,15 @@ class AdminTransmitterDetailSerializer(serializers.ModelSerializer): else: instance.features = [] instance.save(update_fields=['features']) + if professions_objs is not None: + instance.professions_list.set(professions_objs) + if professions_objs: + first_prof = professions_objs[0] + instance.profession = first_prof.title or [] + instance.save(update_fields=['profession']) + else: + instance.profession = [] + instance.save(update_fields=['profession']) if "sahabi_status" in validated_data: st = validated_data.get("sahabi_status") instance.companion_type = st.title if st else [] diff --git a/apps/hadis/urls.py b/apps/hadis/urls.py index 6c3e97a..cb0badf 100644 --- a/apps/hadis/urls.py +++ b/apps/hadis/urls.py @@ -47,8 +47,10 @@ from .views_admin import ( DovodiCityViewSet, DovodiKunyaViewSet, DovodiTransmitterFeatureViewSet, + DovodiTransmitterProfessionViewSet, DovodiSahabiStatusViewSet, - DovodiMadhhabViewSet + DovodiMadhhabViewSet, + DovodiRelationTypeViewSet ) @@ -151,10 +153,14 @@ admin_router.register(r'dovodi-kunyas', DovodiKunyaViewSet, basename='dovodi-kun admin_router.register(r'kunyas', DovodiKunyaViewSet, basename='admin-kunyas') admin_router.register(r'dovodi-transmitter-features', DovodiTransmitterFeatureViewSet, basename='dovodi-transmitter-features') admin_router.register(r'transmitter-features', DovodiTransmitterFeatureViewSet, basename='admin-transmitter-features') +admin_router.register(r'dovodi-transmitter-professions', DovodiTransmitterProfessionViewSet, basename='dovodi-transmitter-professions') +admin_router.register(r'transmitter-professions', DovodiTransmitterProfessionViewSet, basename='admin-transmitter-professions') admin_router.register(r'dovodi-sahabi-statuses', DovodiSahabiStatusViewSet, basename='dovodi-sahabi-statuses') admin_router.register(r'sahabi-statuses', DovodiSahabiStatusViewSet, basename='admin-sahabi-statuses') admin_router.register(r'dovodi-madhhabs', DovodiMadhhabViewSet, basename='dovodi-madhhabs') admin_router.register(r'madhhabs', DovodiMadhhabViewSet, basename='admin-madhhabs') +admin_router.register(r'dovodi-relation-types', DovodiRelationTypeViewSet, basename='dovodi-relation-types') +admin_router.register(r'relation-types', DovodiRelationTypeViewSet, basename='admin-relation-types') diff --git a/apps/hadis/views_admin.py b/apps/hadis/views_admin.py index 0f2cd4d..5172ea1 100644 --- a/apps/hadis/views_admin.py +++ b/apps/hadis/views_admin.py @@ -40,8 +40,10 @@ from .models import ( Kunya, TransmitterFeature, + TransmitterProfession, SahabiStatus, Madhhab, + RelationType, ) @@ -131,8 +133,10 @@ from .serializers.serializers_admin import ( AdminKunyaSerializer, AdminTransmitterFeatureSerializer, + AdminTransmitterProfessionSerializer, AdminSahabiStatusSerializer, AdminMadhhabSerializer, + AdminRelationTypeSerializer, ) @@ -2943,6 +2947,24 @@ class DovodiTransmitterFeatureViewSet(ModelViewSet): return queryset +class DovodiTransmitterProfessionViewSet(ModelViewSet): + serializer_class = AdminTransmitterProfessionSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + pagination_class = OptionalPagination + parser_classes = (JSONParser, MultiPartParser, FormParser) + + def get_queryset(self): + queryset = TransmitterProfession.objects.all().order_by("slug") + search_query = self.request.query_params.get("search") + if search_query: + queryset = queryset.filter( + Q(title__icontains=search_query) + | Q(slug__icontains=search_query) + ) + return queryset + + class DovodiSahabiStatusViewSet(ModelViewSet): serializer_class = AdminSahabiStatusSerializer permission_classes = [IsAuthenticated, IsSuperAdmin] @@ -2977,3 +2999,21 @@ class DovodiMadhhabViewSet(ModelViewSet): | Q(slug__icontains=search_query) ) return queryset + +class DovodiRelationTypeViewSet(ModelViewSet): + serializer_class = AdminRelationTypeSerializer + permission_classes = [IsAuthenticated, IsSuperAdmin] + authentication_classes = [TokenAuthentication] + pagination_class = OptionalPagination + parser_classes = (JSONParser, MultiPartParser, FormParser) + + def get_queryset(self): + queryset = RelationType.objects.all().order_by("slug", "id") + search_query = self.request.query_params.get("search") + if search_query: + queryset = queryset.filter( + Q(title__icontains=search_query) + | Q(slug__icontains=search_query) + | Q(code__icontains=search_query) + ) + return queryset