From 04703df6b5702d56206f1ae5092098f2dee960e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BE=9D=E7=91=AA=E8=B2=93?= Date: Mon, 24 Aug 2020 21:59:50 +0800 Subject: [PATCH] Replaced the combined localized data models into flexible, separated localized data models and their accompanying localization data models, so that adding new languages works automatically without having to change the data model definitions. --- accounting/models.py | 25 ++++--- accounting/utils.py | 10 +-- accounting/views.py | 6 +- mia_core/models.py | 158 ++++++++++++++++++++++++++++++++++++++++++- mia_core/utils.py | 57 ---------------- 5 files changed, 181 insertions(+), 75 deletions(-) diff --git a/accounting/models.py b/accounting/models.py index f23981b..16c6065 100644 --- a/accounting/models.py +++ b/accounting/models.py @@ -28,24 +28,24 @@ from django.db import models, transaction from django.db.models import Q, Max from django.http import HttpRequest -from mia_core.models import BaseModel -from mia_core.utils import get_multi_lingual_attr, set_multi_lingual_attr +from mia_core.models import BaseModel, L10nModel, LocalizedModel -class Account(DirtyFieldsMixin, BaseModel): +class Account(DirtyFieldsMixin, LocalizedModel, BaseModel): """An account.""" parent = models.ForeignKey( "self", on_delete=models.PROTECT, null=True, related_name="child_set") code = models.CharField(max_length=5, unique=True) - title_zh_hant = models.CharField(max_length=32) - title_en = models.CharField(max_length=128, null=True) - title_zh_hans = models.CharField(max_length=32, null=True) + title_l10n = models.CharField(max_length=32, db_column="title") CASH = "1111" ACCUMULATED_BALANCE = "3351" NET_CHANGE = "3353" def __init__(self, *args, **kwargs): + if "title" in kwargs: + self.title = kwargs["title"] + del kwargs["title"] super().__init__(*args, **kwargs) self.url = None self.debit_amount = None @@ -69,12 +69,11 @@ class Account(DirtyFieldsMixin, BaseModel): @property def title(self) -> str: - """The title in the current language.""" - return get_multi_lingual_attr(self, "title") + return self.get_l10n("title") @title.setter - def title(self, value: str) -> None: - set_multi_lingual_attr(self, "title", value) + def title(self, value): + self.set_l10n("title", value) @property def option_data(self) -> Dict[str, str]: @@ -109,6 +108,12 @@ class Account(DirtyFieldsMixin, BaseModel): self._is_in_use = value +class AccountL10n(DirtyFieldsMixin, L10nModel, BaseModel): + """The localization content of an account.""" + master = models.ForeignKey( + Account, on_delete=models.CASCADE, related_name="l10n_set") + + class Transaction(DirtyFieldsMixin, BaseModel): """An accounting transaction.""" date = models.DateField() diff --git a/accounting/utils.py b/accounting/utils.py index 049c8a4..48cd103 100644 --- a/accounting/utils.py +++ b/accounting/utils.py @@ -155,10 +155,12 @@ class DataFiller: code = str(code) parent = None if len(code) == 1\ else Account.objects.get(code=code[:-1]) - Account(pk=new_pk(Account), parent=parent, code=code, - title_zh_hant=data[1], title_en=data[2], - title_zh_hans=data[3], - created_by=self.user, updated_by=self.user).save() + account = Account(parent=parent, code=code) + account.current_user = self.user + account.set_l10n_in("title", "zh-hant", data[1]) + account.set_l10n_in("title", "en", data[2]) + account.set_l10n_in("title", "zh-hans", data[3]) + account.save() def add_transfer_transaction(self, date: Union[datetime.date, int], debit: List[RecordData], diff --git a/accounting/views.py b/accounting/views.py index d666bd8..cd3aee7 100644 --- a/accounting/views.py +++ b/accounting/views.py @@ -39,8 +39,7 @@ from django.views.decorators.http import require_GET, require_POST from django.views.generic import ListView, DetailView from mia_core.period import Period -from mia_core.utils import Pagination, get_multi_lingual_search, \ - PaginationException +from mia_core.utils import Pagination, PaginationException from mia_core.views import DeleteView, FormView, RedirectView from . import utils from .forms import AccountForm, TransactionForm, TransactionSortForm @@ -767,7 +766,8 @@ def search(request: HttpRequest) -> HttpResponse: records = [] else: records = Record.objects.filter( - get_multi_lingual_search("account__title", query) + Q(account__title_l10n__icontains=query) + | Q(account__l10n_set__value__icontains=query) | Q(account__code__icontains=query) | Q(summary__icontains=query) | Q(transaction__notes__icontains=query)) diff --git a/mia_core/models.py b/mia_core/models.py index 886b028..b5fd035 100644 --- a/mia_core/models.py +++ b/mia_core/models.py @@ -18,10 +18,13 @@ """The data models of the Mia core application. """ +from typing import Any, Dict, List + +from dirtyfields import DirtyFieldsMixin from django.conf import settings from django.db import models -from mia_core.utils import new_pk +from mia_core.utils import new_pk, Language class BaseModel(models.Model): @@ -54,3 +57,156 @@ class BaseModel(models.Model): class Meta: abstract = True + + +class LocalizedModel(models.Model): + """An abstract localized model.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if getattr(self, "_l10n", None) is None: + self._l10n: Dict[str, Dict[str, Any]] = {} + # Amends the is_dirty() method in DirtyFieldsMixin + if isinstance(self, DirtyFieldsMixin): + old_is_dirty = getattr(self, "is_dirty", None) + + def new_is_dirty(check_relationship=False, check_m2m=None) -> bool: + """Returns whether the current data model is changed.""" + if old_is_dirty(check_relationship=check_relationship, + check_m2m=check_m2m): + return True + default_language = self._get_default_language() + for name in self._l10n: + for language in self._l10n[name]: + new_value = self._l10n[name][language] + if language == default_language: + if getattr(self, name + "_l10n") != new_value: + return True + else: + l10n_rec = self._get_l10n_set() \ + .filter(name=name, language=language) \ + .first() + if l10n_rec is None or l10n_rec.value != new_value: + return True + return False + self.is_dirty = new_is_dirty + + def save(self, force_insert=False, force_update=False, using=None, + update_fields=None): + """Saves the data model, along with the localized contents.""" + default_language = self._get_default_language() + l10n_to_save: List[models.Model] = [] + if getattr(self, "_l10n", None) is not None: + for name in self._l10n: + for language in self._l10n[name]: + new_value = self._l10n[name][language] + if language == default_language: + setattr(self, name + "_l10n", new_value) + else: + current_value = getattr(self, name + "_l10n") + if current_value is None or current_value == "": + setattr(self, name + "_l10n", new_value) + l10n_rec = self._get_l10n_set()\ + .filter(name=name, language=language)\ + .first() + if l10n_rec is None: + l10n_to_save.append(self._get_l10n_set().model( + master=self, name=name, + language=language, + value=self._l10n[name][language])) + elif l10n_rec.value != new_value: + if getattr(self, name + "_l10n") == l10n_rec.value: + setattr(self, name + "_l10n", new_value) + l10n_rec.value = new_value + l10n_to_save.append(l10n_rec) + super().save(force_insert=force_insert, force_update=force_update, + using=using, update_fields=update_fields) + for l10n_rec in l10n_to_save: + if isinstance(self, BaseModel) and isinstance(l10n_rec, BaseModel): + l10n_rec.current_user = self.current_user + l10n_rec.save(force_insert=force_insert, force_update=force_update, + using=using, update_fields=update_fields) + + def _get_l10n_set(self): + """Returns the related localization data model.""" + l10n_set = getattr(self, "l10n_set", None) + if l10n_set is None: + raise AttributeError("Please define the localization data model.") + return l10n_set + + def _get_default_language(self) -> str: + """Returns the default language.""" + default = getattr(self.__class__, "DEFAULT_LANGUAGE", None) + return Language.default().id if default is None else default + + def get_l10n(self, name: str) -> Any: + """Returns the value of a localized field in the current language. + + Args: + name: The field name. + + Returns: + The value of this field in the current language. + """ + return self.get_l10n_in(name, Language.current().id) + + def get_l10n_in(self, name: str, language: str) -> Any: + """Returns the value of a localized field in a specific language. + + Args: + name: The field name. + language: The language ID. + + Returns: + The value of this field in this language. + """ + if getattr(self, "_l10n", None) is None: + self._l10n: Dict[str, Dict[str, Any]] = {} + if name not in self._l10n: + self._l10n[name]: Dict[str, Any] = {} + if language not in self._l10n[name]: + if language != self._get_default_language(): + l10n_rec = self._get_l10n_set() \ + .filter(name=name, language=language) \ + .first() + self._l10n[name][language] = getattr(self, name + "_l10n") \ + if l10n_rec is None else l10n_rec.value + else: + self._l10n[name][language] = getattr(self, name + "_l10n") + return self._l10n[name][language] + + def set_l10n(self, name: str, value: Any) -> None: + """Sets the value of a localized field in the current language. + + Args: + name: The field name. + value: The value. + """ + self.set_l10n_in(name, Language.current().id, value) + + def set_l10n_in(self, name: str, language: str, value: Any) -> None: + """Sets the value of a localized field in a specific language. + + Args: + name: The field name. + language: The language ID. + value: The value. + """ + if getattr(self, "_l10n", None) is None: + self._l10n: Dict[str, Dict[str, Any]] = {} + if name not in self._l10n: + self._l10n[name]: Dict[str, Any] = {} + self._l10n[name][language] = value + + class Meta: + abstract = True + + +class L10nModel(models.Model): + """The abstract base localization model.""" + name = models.CharField(max_length=128) + language = models.CharField(max_length=7) + value = models.CharField(max_length=65535) + + class Meta: + abstract = True diff --git a/mia_core/utils.py b/mia_core/utils.py index bf908d7..1b48095 100644 --- a/mia_core/utils.py +++ b/mia_core/utils.py @@ -89,63 +89,6 @@ class Language: return Language(get_language()) -def get_multi_lingual_attr(model: Model, name: str, - default: str = None) -> str: - """Returns a multi-lingual attribute of a data model. - - Args: - model: The data model. - name: The attribute name. - default: The default language. - - Returns: - The attribute in this language, or in the default language if there is - no content in the current language. - """ - language = Language.current() - title = getattr(model, name + language.db) - if default is None: - default = Language.default().id - if language.id == default: - return title - if title is not None: - return title - return getattr(model, name + Language.default().db) - - -def set_multi_lingual_attr(model: Model, name: str, value: str) -> None: - """Sets a multi-lingual attribute of a data model. - - Args: - model: The data model. - name: The attribute name. - value: The new value - """ - language = Language.current() - setattr(model, name + language.db, value) - - -def get_multi_lingual_search(attr: str, query: str) -> Q: - """Returns the query condition on a multi-lingual attribute. - - Args: - attr: The base name of the multi-lingual attribute. - query: The query. - - Returns: - The query condition - """ - language = Language.current() - if language.is_default: - return Q(**{attr + language.db + "__icontains": query}) - default = Language.default() - q = (Q(**{attr + language.db + "__isnull": False}) - & Q(**{attr + language.db + "__icontains": query}))\ - | (Q(**{attr + language.db + "__isnull": True}) - & Q(**{attr + default.db + "__icontains": query})) - return q - - class UrlBuilder: """The URL builder.