diff --git a/apis_ontology/admin.py b/apis_ontology/admin.py new file mode 100644 index 0000000..7fc55ea --- /dev/null +++ b/apis_ontology/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin +from parler.admin import TranslatableAdmin + +from .models import Person + + +@admin.register(Person) +class PersonAdmin(TranslatableAdmin): + # TODO: allow editing the translations here? + list_display = ["name", "all_languages_column"] + pass diff --git a/apis_ontology/filtersets.py b/apis_ontology/filtersets.py index 7a2d4d0..cbd48e9 100644 --- a/apis_ontology/filtersets.py +++ b/apis_ontology/filtersets.py @@ -1,3 +1,4 @@ +from django.conf.global_settings import LANGUAGE_CODE import django_filters from apis_core.apis_entities.filtersets import ( ABSTRACT_ENTITY_FILTERS_EXCLUDE, @@ -9,6 +10,7 @@ from apis_core.relations.models import Relation from django.apps import apps from django.db import models +from django.utils.translation import get_language from apis_ontology.forms import ( PersonSearchForm, @@ -212,9 +214,10 @@ class Meta: ) def custom_name_search(self, queryset, name, value): - name_query = models.Q(name__icontains=value) | models.Q( - alternative_names__icontains=value - ) + name_query = models.Q( + translations__name__icontains=value, + translations__language_code=get_language(), + ) | models.Q(alternative_names__icontains=value) if value.isdigit(): name_query = name_query | models.Q(pk=int(value)) diff --git a/apis_ontology/forms.py b/apis_ontology/forms.py index 3f538ef..b844311 100644 --- a/apis_ontology/forms.py +++ b/apis_ontology/forms.py @@ -1,9 +1,9 @@ -from apis_core.relations.models import Relation from apis_core.relations.forms import RelationForm from django import forms from apis_core.generic.forms import GenericFilterSetForm, GenericModelForm -from django.forms.models import ModelChoiceField from django.apps import apps +from parler.fields import TranslatedField +from parler.forms import TranslatableModelForm class TibscholEntityForm(GenericModelForm): @@ -35,7 +35,9 @@ class PlaceForm(TibscholEntityForm): ] -class PersonForm(TibscholEntityForm): +class PersonForm(TranslatableModelForm, TibscholEntityForm): + name = TranslatedField() + field_order = [ "name", "alternative_names", @@ -49,6 +51,13 @@ class PersonForm(TibscholEntityForm): "review", ] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields["name"].help_text = ( + "Equivalent transliterations in Tibetan/English inputs will be automatically provided at the time of creation. However, updates to this field will NOT trigger any updates to the values in other languages." + ) + class WorkForm(TibscholEntityForm): field_order = [ diff --git a/apis_ontology/management/commands/support_tibetan_script_001.py b/apis_ontology/management/commands/support_tibetan_script_001.py new file mode 100644 index 0000000..d540ac1 --- /dev/null +++ b/apis_ontology/management/commands/support_tibetan_script_001.py @@ -0,0 +1,26 @@ +import pyewts +from django.core.management.base import BaseCommand +from tqdm.auto import tqdm +from apis_ontology.models import Person +from tqdm.auto import tqdm + +TIBETAN = "es" + + +class Command(BaseCommand): + help = "add Tibetan transliterations for Person - names" + + def handle(self, *args, **options): + converter = pyewts.pyewts() + + for p in tqdm(Person.objects.all()): + tibetan_name = converter.toUnicode(p.name_latin) + p.set_current_language(TIBETAN) + p.name = tibetan_name + p.save() + + self.stdout.write( + self.style.SUCCESS( + f"{len(Person.objects.all())} names were successfully transliterated into Tibetan." + ) + ) diff --git a/apis_ontology/middleware/__init__.py b/apis_ontology/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apis_ontology/migrations/0032_rename_name_person_name_latin_and_more.py b/apis_ontology/migrations/0032_rename_name_person_name_latin_and_more.py new file mode 100644 index 0000000..2b61043 --- /dev/null +++ b/apis_ontology/migrations/0032_rename_name_person_name_latin_and_more.py @@ -0,0 +1,74 @@ +# Generated by Django 5.1.3 on 2024-11-17 00:11 + +import django.db.models.deletion +import parler.fields +import parler.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("apis_ontology", "0031_versioninstancecopiedwrittendownatplace_and_more"), + ] + + operations = [ + migrations.RenameField( + model_name="person", + old_name="name", + new_name="name_latin", + ), + migrations.RenameField( + model_name="versionperson", + old_name="name", + new_name="name_latin", + ), + migrations.CreateModel( + name="PersonTranslation", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "language_code", + models.CharField( + db_index=True, max_length=15, verbose_name="Language" + ), + ), + ( + "name", + models.CharField( + blank=True, default="", max_length=255, verbose_name="Name" + ), + ), + ( + "master", + parler.fields.TranslationsForeignKey( + editable=False, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="translations", + to="apis_ontology.person", + ), + ), + ], + options={ + "verbose_name": "person Translation", + "db_table": "apis_ontology_person_translation", + "db_tablespace": "", + "managed": True, + "default_permissions": (), + "unique_together": {("language_code", "master")}, + }, + bases=(parler.models.TranslatableModel, models.Model), + ), + migrations.RunSQL( + "INSERT INTO apis_ontology_person_translation(language_code, name , master_id) SELECT 'en', name_latin, rootobject_ptr_id FROM apis_ontology_person;" + ), + ] diff --git a/apis_ontology/models.py b/apis_ontology/models.py index 24a2041..202dd7b 100644 --- a/apis_ontology/models.py +++ b/apis_ontology/models.py @@ -14,6 +14,10 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ +from parler.models import TranslatableModel, TranslatedFields + +from apis_ontology.querysets import PersonQuerySet +from django.utils.translation import get_language logger = logging.getLogger(__name__) @@ -69,7 +73,12 @@ def uri(self): class Person( - VersionMixin, LegacyStuffMixin, LegacyDateMixin, TibScholEntityMixin, AbstractEntity + VersionMixin, + LegacyStuffMixin, + LegacyDateMixin, + TibScholEntityMixin, + AbstractEntity, + TranslatableModel, ): class_uri = "http://id.loc.gov/ontologies/bibframe/Person" @@ -92,18 +101,27 @@ class Person( ] NATIONALITY = [("Indic", "Indic"), ("Tibetan", "Tibetan")] - name = models.CharField(max_length=255, blank=True, default="", verbose_name="Name") + name_latin = models.CharField( + max_length=255, blank=True, default="", verbose_name="Name" + ) + translations = TranslatedFields( + name=models.CharField( + max_length=255, blank=True, default="", verbose_name="Name" + ) + ) gender = models.CharField(max_length=6, choices=GENDERS, default="male") nationality = models.CharField( max_length=10, choices=NATIONALITY, blank=True, null=True ) + def __str__(self): + return f"{self.name} ({self.pk})" + class Meta: verbose_name = _("person") verbose_name_plural = _("Persons") - def __str__(self): - return f"{self.name} ({self.pk})" + objects = PersonQuerySet.as_manager() class Place( @@ -137,6 +155,8 @@ def __str__(self): class WorkQuerySet(QuerySet): def with_author(self): + current_language = get_language() + return self.annotate( # Subquery to get the Person ID related to the Work through PersonAuthorOfWork author_id=Subquery( @@ -146,7 +166,9 @@ def with_author(self): ), # Subquery to get the Person's name based on the person_id from above author_name=Subquery( - Person.objects.filter(id=OuterRef("author_id")).values("name")[:1] + Person.objects.filter(id=OuterRef("author_id")) + .filter(translations__language_code=current_language) + .values("translations__name")[:1] ), ) @@ -202,6 +224,7 @@ def __str__(self): class InstanceQuerySet(QuerySet): def with_author(self): + current_language = get_language() return self.annotate( # Subquery to get the Person ID related to the Work through PersonAuthorOfWork work_id=Subquery( @@ -216,7 +239,9 @@ def with_author(self): ), # Subquery to get the Person's name based on the person_id from above author_name=Subquery( - Person.objects.filter(id=OuterRef("author_id")).values("name")[:1] + Person.objects.filter(id=OuterRef("author_id")) + .filter(translations__language_code=current_language) + .values("translations__name")[:1] ), ) diff --git a/apis_ontology/querysets.py b/apis_ontology/querysets.py new file mode 100644 index 0000000..2a17199 --- /dev/null +++ b/apis_ontology/querysets.py @@ -0,0 +1,5 @@ +from parler.managers import TranslatableQuerySet + + +class PersonQuerySet(TranslatableQuerySet): + pass diff --git a/apis_ontology/settings/server_settings.py b/apis_ontology/settings/server_settings.py index 147ec53..0fd1d9c 100644 --- a/apis_ontology/settings/server_settings.py +++ b/apis_ontology/settings/server_settings.py @@ -1,4 +1,7 @@ +import os from apis_acdhch_default_settings.settings import * +from django.utils.translation import gettext_lazy as _ +from django.conf.locale import LANG_INFO # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -17,6 +20,7 @@ "apis_core.history", "django_acdhch_functions", "django_select2", + "parler", ] INSTALLED_APPS.remove("apis_ontology") INSTALLED_APPS.insert(0, "apis_ontology") @@ -65,4 +69,51 @@ MIDDLEWARE += [ "simple_history.middleware.HistoryRequestMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.locale.LocaleMiddleware", # Enables language switching based on session + # "apis_ontology.middleware.language_change_middleware.LanguageChangeMiddleware", ] + + +LANG_INFO.update( + { + "bo": { + "bidi": False, # Set to True if the language is written right-to-left + "code": "bo", + "name": "Tibetan", + "name_local": "བོད་ཡིག", # Native name + }, + } +) + +LANGUAGE_CODE = "en" # This will be the default language + +# Locale paths (optional if you store translations in a custom directory) +# We currently use only model translations on specific fields +# LOCALE_PATHS = [ +# BASE_DIR / 'locale', +# ] + +PARLER_LANGUAGES = { + None: ( # Site ID 1 + {"code": "en"}, + {"code": "es"}, + ), + "default": { + "fallback": "en", # Use English if translation is missing + "hide_untranslated": False, # Show default values for missing translations + }, +} + + +## List of available languages in the app +LANGUAGES = [ + ("en", _("English")), + ("es", _("Tibetan")), +] + +LOCALE_PATHS = (os.path.join(BASE_DIR, "apis_ontology", "locale/"),) + +USE_I18N = True +USE_L10N = True +USE_TZ = True diff --git a/apis_ontology/signals.py b/apis_ontology/signals.py index f284267..2c174d4 100644 --- a/apis_ontology/signals.py +++ b/apis_ontology/signals.py @@ -1,11 +1,20 @@ import os from django.contrib.auth.signals import user_logged_in +from django.db.models.base import pre_save, post_save from django.dispatch import receiver from django.contrib.auth.models import Group from django.db.models.signals import pre_delete from apis_core.apis_entities.models import RootObject from apis_core.relations.models import Relation +from apis_ontology.models import Person, PersonTranslation +from parler.signals import post_translation_save +from django.dispatch import receiver +import pyewts + +converter = pyewts.pyewts() +TIBETAN = "es" + @receiver(user_logged_in) def add_to_group(sender, user, request, **kwargs): @@ -19,3 +28,58 @@ def add_to_group(sender, user, request, **kwargs): def cascade_delete_related(sender, instance, **kwargs): Relation.objects.filter(subj_object_id=instance.pk).delete() Relation.objects.filter(obj_object_id=instance.pk).delete() + + +@receiver(post_save, sender=Person) +def fill_missing_translations(sender, instance, created, **kwargs): + + if not created: + # Only add translations when a new entity is created + return + + current_language = instance.get_current_language() + target_language = "en" if current_language == TIBETAN else TIBETAN + + # Assumption here is that object is created at first in one language + + def generate_translation(base_instance, base_language, target_language): + # Replace with your translation logic + fields_with_translations = ["name"] + translations = {} + instance.set_current_language(current_language) + + for f in fields_with_translations: + base_lang_val = getattr(base_instance, f) + if target_language == TIBETAN: + translations[f] = { + current_language: base_lang_val, + target_language: converter.toUnicode(base_lang_val), + } + else: + translations[f] = { + current_language: base_lang_val, + target_language: converter.toWylie(base_lang_val), + } + + return translations + + translations = generate_translation(instance, current_language, target_language) + instance.set_current_language(target_language) + for f, tr in translations.items(): + setattr(instance, f, tr[target_language]) + + instance.save() + + +# post save signal to update translations for person.name +@receiver(post_save, sender=Person) +def update_person_translations(sender, instance, **kwargs): + pass + # TODO: Depending on which language is used while creating objects + # we should create the translation object using pyewts + # if instance.name: + # PersonTranslation.objects.update_or_create( + # master=instance, + # language_code="bo", + # defaults={"name": instance.name}, + # ) diff --git a/apis_ontology/tables.py b/apis_ontology/tables.py index e949576..ba3490b 100644 --- a/apis_ontology/tables.py +++ b/apis_ontology/tables.py @@ -6,7 +6,7 @@ from django.template.loader import render_to_string from django.utils.safestring import mark_safe import re - +from apis_core.history.tables import HistoryGenericTable from .models import Instance, Person, Place, Work from .templatetags.filter_utils import ( render_coordinate, @@ -347,3 +347,9 @@ def __init__(self, *args, **kwargs): # + " - " # + (author.end_date_written if author.end_date_written else "") # ) + + +class PersonHistoryTable(tables.Table): + # There is a problem in accessing Name + # TODO: FInd out how to modiy this to use HistoryGenericTable + fields_changed = None diff --git a/apis_ontology/templates/base.html b/apis_ontology/templates/base.html index 1194af0..eff603f 100644 --- a/apis_ontology/templates/base.html +++ b/apis_ontology/templates/base.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% load i18n %} {% load static %} {% load tibschol_entity_ctypes %} @@ -32,8 +33,31 @@ Data model -{{ block.super }} -{% endblock main-menu %} + + {{ block.super }} + + + {% endblock main-menu %} {% block modal %} {{ block.super }} diff --git a/apis_ontology/urls.py b/apis_ontology/urls.py index 2586a04..411f42d 100644 --- a/apis_ontology/urls.py +++ b/apis_ontology/urls.py @@ -3,15 +3,20 @@ from django.urls import include, path from django.views.generic import TemplateView from django.contrib.staticfiles.urls import staticfiles_urlpatterns +from django.conf.urls.i18n import i18n_patterns from apis_core.apis_entities.api_views import GetEntityGeneric - +from django.conf.urls.i18n import set_language urlpatterns = [ path("admin/", admin.site.urls), - path("apis/", include("apis_core.urls", namespace="apis")), - path("apis/collections/", include("apis_core.collections.urls")), +] + +# I18N patterns +urlpatterns += i18n_patterns( path("accounts/", include("django.contrib.auth.urls")), + path("apis/", include("apis_core.urls")), + path("apis/collections/", include("apis_core.collections.urls")), path("entity//", GetEntityGeneric.as_view(), name="GetEntityGenericRoot"), path("", TemplateView.as_view(template_name="base.html")), path( @@ -19,13 +24,16 @@ ExcerptsView.as_view(), name="excerpts_view", ), -] + prefix_default_language=True, +) +# Static files and other patterns urlpatterns += staticfiles_urlpatterns() -urlpatterns += [ - path("", include("django_acdhch_functions.urls")), -] +# Additional URLs urlpatterns += [ + path("", include("django_acdhch_functions.urls")), path("select2/", include("django_select2.urls")), + path("i18n/", include("django.conf.urls.i18n")), + path("set-language/", set_language, name="set_language"), ] diff --git a/apis_ontology/views.py b/apis_ontology/views.py index 468af99..cb12408 100644 --- a/apis_ontology/views.py +++ b/apis_ontology/views.py @@ -1,7 +1,10 @@ +from django.shortcuts import redirect +from django.utils.translation import activate from django.http import HttpResponse from django.views import View from django.shortcuts import get_object_or_404 from .models import Excerpts +import parler class ExcerptsView(View): diff --git a/poetry.lock b/poetry.lock index b71f13a..c888721 100644 --- a/poetry.lock +++ b/poetry.lock @@ -855,6 +855,20 @@ files = [ anytree = "*" Django = "*" +[[package]] +name = "django-parler" +version = "2.3" +description = "Simple Django model translations without nasty hacks, featuring nice admin integration." +optional = false +python-versions = "*" +files = [ + {file = "django-parler-2.3.tar.gz", hash = "sha256:2c8f5012ceb5e49af93b16ea3fe4d0c83d70b91b2d0f470c05d7d742b6f3083d"}, + {file = "django_parler-2.3-py3-none-any.whl", hash = "sha256:8f6c8061e4b5690f1ee2d8e5760940ef06bf78a5bfa033d11178377559c749cf"}, +] + +[package.dependencies] +Django = ">=2.2" + [[package]] name = "django-select2" version = "8.2.1" @@ -920,17 +934,6 @@ files = [ [package.dependencies] django = ">=4.2" -[[package]] -name = "dot" -version = "0.3.0" -description = "Providing context to models..." -optional = false -python-versions = "<4.0,>=3.11" -files = [ - {file = "dot-0.3.0-py3-none-any.whl", hash = "sha256:9f43c4ed466315803a354908486c67a7f981b10f1fdb55cb45aeac6ddd2dd0de"}, - {file = "dot-0.3.0.tar.gz", hash = "sha256:3543921eefa82923870fc752ebdadc593962e0d0db507d27daab9528c05ceb58"}, -] - [[package]] name = "drf-spectacular" version = "0.27.2" @@ -1010,22 +1013,6 @@ protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4 [package.extras] grpc = ["grpcio (>=1.44.0,<2.0.0.dev0)"] -[[package]] -name = "graphviz" -version = "0.20.3" -description = "Simple Python interface for Graphviz" -optional = false -python-versions = ">=3.8" -files = [ - {file = "graphviz-0.20.3-py3-none-any.whl", hash = "sha256:81f848f2904515d8cd359cc611faba817598d2feaac4027b266aa3eda7b3dde5"}, - {file = "graphviz-0.20.3.zip", hash = "sha256:09d6bc81e6a9fa392e7ba52135a9d49f1ed62526f96499325930e87ca1b5925d"}, -] - -[package.extras] -dev = ["flake8", "pep8-naming", "tox (>=3)", "twine", "wheel"] -docs = ["sphinx (>=5,<7)", "sphinx-autodoc-typehints", "sphinx-rtd-theme"] -test = ["coverage", "pytest (>=7,<8.1)", "pytest-cov", "pytest-mock (>=3)"] - [[package]] name = "grpcio" version = "1.67.1" @@ -2736,6 +2723,16 @@ dev = ["chardet", "parameterized", "ruff"] release = ["zest.releaser[recommended]"] tests = ["chardet", "parameterized", "pytest", "pytest-cov", "pytest-xdist[psutil]", "ruff", "tox"] +[[package]] +name = "pyewts" +version = "0.2.0" +description = "Python utils for EWTS conversion from / to Unicode" +optional = false +python-versions = ">=3.4" +files = [ + {file = "pyewts-0.2.0.tar.gz", hash = "sha256:6fb7b4e1abcb7b98d57d48d2fbc0ad0acc45d3d86341c059d1884ca26c924ee0"}, +] + [[package]] name = "pygments" version = "2.18.0" @@ -3465,6 +3462,20 @@ files = [ docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<8.2)", "pytest-mock", "pytest-mypy-testing"] +[[package]] +name = "transliterate" +version = "1.10.2" +description = "Bi-directional transliterator for Python" +optional = false +python-versions = "*" +files = [ + {file = "transliterate-1.10.2-py2.py3-none-any.whl", hash = "sha256:010a5021bf6021689c4fade0985f3f7b3db1f2f16a48a09a56797f171c08ed42"}, + {file = "transliterate-1.10.2.tar.gz", hash = "sha256:bc608e0d48e687db9c2b1d7ea7c381afe0d1849cad216087d8e03d8d06a57c85"}, +] + +[package.dependencies] +six = ">=1.1.0" + [[package]] name = "types-python-dateutil" version = "2.9.0.20241003" @@ -3715,4 +3726,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "d6bcd34a1ada7e5b8a5cfda34327d7f2a9451a26de9a835f9cb195d2e3d255f1" +content-hash = "ea86d922f797ad8fd4cec5e2fa14de4c9fbab3e43d833622cee2368729a9a29d" diff --git a/pyproject.toml b/pyproject.toml index deefe0d..7d31c47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,9 @@ pandas = "^2.2.2" django-select2 = "^8.1.2" lxml = "^5.2.2" sqlparse = "^0.5.1" +pyewts = "^0.2.0" +django-parler = "^2.3" +transliterate = "^1.10.2" [tool.poetry.group.dev.dependencies]