diff --git a/core/choices.py b/core/choices.py new file mode 100644 index 0000000..d636bb4 --- /dev/null +++ b/core/choices.py @@ -0,0 +1,220 @@ +from django.utils.translation import gettext_lazy as _ + +LANGUAGE = [ + ("aa", "Afar"), + ("af", "Afrikaans"), + ("ak", "Akan"), + ("sq", "Albanian"), + ("am", "Amharic"), + ("ar", "Arabic"), + ("an", "Aragonese"), + ("hy", "Armenian"), + ("as", "Assamese"), + ("av", "Avaric"), + ("ae", "Avestan"), + ("ay", "Aymara"), + ("az", "Azerbaijani"), + ("bm", "Bambara"), + ("ba", "Bashkir"), + ("eu", "Basque"), + ("be", "Belarusian"), + ("bn", "Bengali"), + ("bi", "Bislama"), + ("bs", "Bosnian"), + ("br", "Breton"), + ("bg", "Bulgarian"), + ("my", "Burmese"), + ("ca", "Catalan, Valencian"), + ("ch", "Chamorro"), + ("ce", "Chechen"), + ("ny", "Chichewa, Chewa, Nyanja"), + ("zh", "Chinese"), + ( + "cu", + "Church Slavic, Old Slavonic, Church Slavonic, Old Bulgarian, Old Church Slavonic", + ), + ("cv", "Chuvash"), + ("kw", "Cornish"), + ("co", "Corsican"), + ("cr", "Cree"), + ("hr", "Croatian"), + ("cs", "Czech"), + ("da", "Danish"), + ("dv", "Divehi, Dhivehi, Maldivian"), + ("nl", "Dutch, Flemish"), + ("dz", "Dzongkha"), + ("en", "English"), + ("eo", "Esperanto"), + ("et", "Estonian"), + ("ee", "Ewe"), + ("fo", "Faroese"), + ("fj", "Fijian"), + ("fi", "Finnish"), + ("fr", "French"), + ("fy", "Western Frisian"), + ("ff", "Fulah"), + ("gd", "Gaelic, Scottish Gaelic"), + ("gl", "Galician"), + ("lg", "Ganda"), + ("ka", "Georgian"), + ("de", "German"), + ("el", "Greek, Modern (1453–)"), + ("kl", "Kalaallisut, Greenlandic"), + ("gn", "Guarani"), + ("gu", "Gujarati"), + ("ht", "Haitian, Haitian Creole"), + ("ha", "Hausa"), + ("he", "Hebrew"), + ("hz", "Herero"), + ("hi", "Hindi"), + ("ho", "Hiri Motu"), + ("hu", "Hungarian"), + ("is", "Icelandic"), + ("io", "Ido"), + ("ig", "Igbo"), + ("id", "Indonesian"), + ("ia", "Interlingua (International Auxiliary Language Association)"), + ("ie", "Interlingue, Occidental"), + ("iu", "Inuktitut"), + ("ik", "Inupiaq"), + ("ga", "Irish"), + ("it", "Italian"), + ("ja", "Japanese"), + ("jv", "Javanese"), + ("kn", "Kannada"), + ("kr", "Kanuri"), + ("ks", "Kashmiri"), + ("kk", "Kazakh"), + ("km", "Central Khmer"), + ("ki", "Kikuyu, Gikuyu"), + ("rw", "Kinyarwanda"), + ("ky", "Kirghiz, Kyrgyz"), + ("kv", "Komi"), + ("kg", "Kongo"), + ("ko", "Korean"), + ("kj", "Kuanyama, Kwanyama"), + ("ku", "Kurdish"), + ("lo", "Lao"), + ("la", "Latin"), + ("lv", "Latvian"), + ("li", "Limburgan, Limburger, Limburgish"), + ("ln", "Lingala"), + ("lt", "Lithuanian"), + ("lu", "Luba-Katanga"), + ("lb", "Luxembourgish, Letzeburgesch"), + ("mk", "Macedonian"), + ("mg", "Malagasy"), + ("ms", "Malay"), + ("ml", "Malayalam"), + ("mt", "Maltese"), + ("gv", "Manx"), + ("mi", "Maori"), + ("mr", "Marathi"), + ("mh", "Marshallese"), + ("mn", "Mongolian"), + ("na", "Nauru"), + ("nv", "Navajo, Navaho"), + ("nd", "North Ndebele"), + ("nr", "South Ndebele"), + ("ng", "Ndonga"), + ("ne", "Nepali"), + ("no", "Norwegian"), + ("nb", "Norwegian Bokmål"), + ("nn", "Norwegian Nynorsk"), + ("ii", "Sichuan Yi, Nuosu"), + ("oc", "Occitan"), + ("oj", "Ojibwa"), + ("or", "Oriya"), + ("om", "Oromo"), + ("os", "Ossetian, Ossetic"), + ("pi", "Pali"), + ("ps", "Pashto, Pushto"), + ("fa", "Persian"), + ("pl", "Polish"), + ("pt", "Português"), + ("pa", "Punjabi, Panjabi"), + ("qu", "Quechua"), + ("ro", "Romanian, Moldavian, Moldovan"), + ("rm", "Romansh"), + ("rn", "Rundi"), + ("ru", "Russian"), + ("se", "Northern Sami"), + ("sm", "Samoan"), + ("sg", "Sango"), + ("sa", "Sanskrit"), + ("sc", "Sardinian"), + ("sr", "Serbian"), + ("sn", "Shona"), + ("sd", "Sindhi"), + ("si", "Sinhala, Sinhalese"), + ("sk", "Slovak"), + ("sl", "Slovenian"), + ("so", "Somali"), + ("st", "Southern Sotho"), + ("es", "Español"), + ("su", "Sundanese"), + ("sw", "Swahili"), + ("ss", "Swati"), + ("sv", "Swedish"), + ("tl", "Tagalog"), + ("ty", "Tahitian"), + ("tg", "Tajik"), + ("ta", "Tamil"), + ("tt", "Tatar"), + ("te", "Telugu"), + ("th", "Thai"), + ("bo", "Tibetan"), + ("ti", "Tigrinya"), + ("to", "Tonga (Tonga Islands)"), + ("ts", "Tsonga"), + ("tn", "Tswana"), + ("tr", "Turkish"), + ("tk", "Turkmen"), + ("tw", "Twi"), + ("ug", "Uighur, Uyghur"), + ("uk", "Ukrainian"), + ("ur", "Urdu"), + ("uz", "Uzbek"), + ("ve", "Venda"), + ("vi", "Vietnamese"), + ("vo", "Volapük"), + ("wa", "Walloon"), + ("cy", "Welsh"), + ("wo", "Wolof"), + ("xh", "Xhosa"), + ("yi", "Yiddish"), + ("yo", "Yoruba"), + ("za", "Zhuang, Chuang"), + ("zu", "Zulu"), +] + +MONTHS = [ + ("01", _("January")), + ("02", _("February")), + ("03", _("March")), + ("04", _("April")), + ("05", _("May")), + ("06", _("June")), + ("07", _("July")), + ("08", _("August")), + ("09", _("September")), + ("10", _("October")), + ("11", _("November")), + ("12", _("December")), +] + +# https://creativecommons.org/share-your-work/cclicenses/ +# There are six different license types, listed from most to least permissive here: +LICENSE_TYPES = [ + ("by", _("by")), + ("by-sa", _("by-sa")), + ("by-nc", _("by-nc")), + ("by-nc-sa", _("by-nc-sa")), + ("by-nd", _("by-nd")), + ("by-nc-nd", _("by-nc-nd")), +] + +GENDER_CHOICES = [ + ("M", _("Male")), + ("F", _("Female")), +] diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py new file mode 100644 index 0000000..8df4a7c --- /dev/null +++ b/core/migrations/0001_initial.py @@ -0,0 +1,114 @@ +# Generated by Django 5.0.8 on 2026-03-24 12:30 + +import django.db.models.deletion +import wagtail.fields +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='FlexibleDate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('year', models.IntegerField(blank=True, null=True, verbose_name='Year')), + ('month', models.IntegerField(blank=True, null=True, verbose_name='Month')), + ('day', models.IntegerField(blank=True, null=True, verbose_name='Day')), + ], + ), + migrations.CreateModel( + name='Language', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True, verbose_name='Creation date')), + ('updated', models.DateTimeField(auto_now=True, verbose_name='Last update date')), + ('name', models.TextField(blank=True, null=True, verbose_name='Language Name')), + ('code2', models.TextField(blank=True, null=True, verbose_name='Language code 2')), + ('creator', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_creator', to=settings.AUTH_USER_MODEL, verbose_name='Creator')), + ('updated_by', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_last_mod_user', to=settings.AUTH_USER_MODEL, verbose_name='Updater')), + ], + options={ + 'verbose_name': 'Language', + 'verbose_name_plural': 'Languages', + }, + ), + migrations.CreateModel( + name='License', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True, verbose_name='Creation date')), + ('updated', models.DateTimeField(auto_now=True, verbose_name='Last update date')), + ('license_type', models.CharField(blank=True, max_length=16, null=True)), + ('creator', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_creator', to=settings.AUTH_USER_MODEL, verbose_name='Creator')), + ('updated_by', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_last_mod_user', to=settings.AUTH_USER_MODEL, verbose_name='Updater')), + ], + options={ + 'verbose_name': 'License', + 'verbose_name_plural': 'Licenses', + }, + ), + migrations.CreateModel( + name='LicenseStatement', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True, verbose_name='Creation date')), + ('updated', models.DateTimeField(auto_now=True, verbose_name='Last update date')), + ('url', models.CharField(blank=True, max_length=255, null=True)), + ('license_p', wagtail.fields.RichTextField(blank=True, null=True)), + ('creator', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_creator', to=settings.AUTH_USER_MODEL, verbose_name='Creator')), + ('language', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.language')), + ('updated_by', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_last_mod_user', to=settings.AUTH_USER_MODEL, verbose_name='Updater')), + ], + options={ + 'verbose_name': 'License Statement', + 'verbose_name_plural': 'License Statements', + }, + ), + migrations.CreateModel( + name='Gender', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True, verbose_name='Creation date')), + ('updated', models.DateTimeField(auto_now=True, verbose_name='Last update date')), + ('code', models.CharField(blank=True, max_length=5, null=True, verbose_name='Code')), + ('gender', models.CharField(blank=True, max_length=50, null=True, verbose_name='Sex')), + ('creator', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_creator', to=settings.AUTH_USER_MODEL, verbose_name='Creator')), + ('updated_by', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_last_mod_user', to=settings.AUTH_USER_MODEL, verbose_name='Updater')), + ], + options={ + 'unique_together': {('code', 'gender')}, + }, + ), + migrations.AddIndex( + model_name='language', + index=models.Index(fields=['code2'], name='core_langua_code2_4f7261_idx'), + ), + migrations.AddIndex( + model_name='language', + index=models.Index(fields=['name'], name='core_langua_name_4f83d7_idx'), + ), + migrations.AddIndex( + model_name='license', + index=models.Index(fields=['license_type'], name='core_licens_license_5d1905_idx'), + ), + migrations.AlterUniqueTogether( + name='license', + unique_together={('license_type',)}, + ), + migrations.AddIndex( + model_name='licensestatement', + index=models.Index(fields=['url'], name='core_licens_url_ec8078_idx'), + ), + migrations.AlterUniqueTogether( + name='licensestatement', + unique_together={('url', 'license_p', 'language')}, + ), + ] diff --git a/core/models.py b/core/models.py index d6e90be..8199d2c 100644 --- a/core/models.py +++ b/core/models.py @@ -1,9 +1,19 @@ -from django.db import models +import os + from django.contrib.auth import get_user_model +from django.db import IntegrityError, models +from django.db.models import Case, IntegerField, Value, When from django.utils.translation import gettext_lazy as _ +from wagtail.admin.panels import FieldPanel +from wagtail.fields import RichTextField +from wagtailautocomplete.edit_handlers import AutocompletePanel + +from core import choices +from core.utils.utils import language_iso User = get_user_model() + class CommonControlField(models.Model): """ Class with common control fields. @@ -42,5 +52,420 @@ class CommonControlField(models.Model): on_delete=models.SET_NULL, ) + class Meta: + abstract = True + + +class Gender(CommonControlField): + """ + Class of gender + + Fields: + code: Gender code + gender: Gender description + """ + + code = models.CharField(_("Code"), max_length=5, null=True, blank=True) + gender = models.CharField(_("Sex"), max_length=50, null=True, blank=True) + + autocomplete_search_filter = "code" + + def autocomplete_label(self): + return str(self) + + panels = [ + FieldPanel("code"), + FieldPanel("gender"), + ] + + class Meta: + unique_together = [("code", "gender")] + + def __unicode__(self): + return self.gender or self.code + + def __str__(self): + return self.gender or self.code + + @classmethod + def load(cls, user): + for item in choices.GENDER_CHOICES: + code, value = item + cls.create_or_update(user, code=code, gender=value) + + @classmethod + def _get(cls, code=None, gender=None): + try: + return cls.objects.get(code=code, gender=gender) + except cls.MultipleObjectsReturned: + return cls.objects.filter(code=code, gender=gender).first() + + @classmethod + def _create(cls, user, code=None, gender=None): + try: + obj = cls() + obj.gender = gender + obj.code = code + obj.creator = user + obj.save() + return obj + except IntegrityError: + return cls._get(code, gender) + + @classmethod + def create_or_update(cls, user, code, gender=None): + try: + return cls._get(code, gender) + except cls.DoesNotExist: + return cls._create(user, code, gender) + + +class Language(CommonControlField): + """ + Represent the list of languages + + Fields: + name + code2 + """ + + name = models.TextField(_("Language Name"), blank=True, null=True) + code2 = models.TextField(_("Language code 2"), blank=True, null=True) + + autocomplete_search_field = "name" + + def autocomplete_label(self): + return str(self) + + class Meta: + verbose_name = _("Language") + verbose_name_plural = _("Languages") + indexes = [ + models.Index( + fields=[ + "code2", + ] + ), + models.Index( + fields=[ + "name", + ] + ), + ] + + def __unicode__(self): + if self.name or self.code2: + return f"{self.name} | {self.code2}" + return "None" + + def __str__(self): + if self.name or self.code2: + return f"{self.name} | {self.code2}" + return "None" + + @classmethod + def load(cls, user): + if cls.objects.count() == 0: + for k, v in choices.LANGUAGE: + cls.get_or_create(name=v, code2=k, creator=user) + + @classmethod + def get_or_create(cls, name=None, code2=None, creator=None): + code2 = language_iso(code2) + if code2: + try: + return cls.objects.get(code2=code2) + except cls.DoesNotExist: + pass + + if name: + try: + return cls.objects.get(name=name) + except cls.DoesNotExist: + pass + + if name or code2: + obj = Language() + obj.name = name + obj.code2 = code2 or "" + obj.creator = creator + obj.save() + return obj + + @classmethod + def get(cls, code2): + if not code2: + raise ValueError("Language.get requires params: code2") + if isinstance(code2, Language): + return code2 + try: + return cls.objects.get(code2=code2) + except cls.DoesNotExist: + return cls.objects.get(code2=language_iso(code2)) + + +class TextWithLang(models.Model): + text = models.TextField(_("Text"), null=True, blank=True) + language = models.ForeignKey( + Language, + on_delete=models.SET_NULL, + verbose_name=_("Language"), + null=True, + blank=True, + ) + + panels = [FieldPanel("text"), AutocompletePanel("language")] + + class Meta: + abstract = True + + +class TextLanguageMixin(models.Model): + rich_text = RichTextField(_("Rich Text"), null=True, blank=True) + plain_text = models.TextField(_("Plain Text"), null=True, blank=True) + language = models.ForeignKey( + Language, + on_delete=models.SET_NULL, + verbose_name=_("Language"), + null=True, + blank=True, + ) + + panels = [ + AutocompletePanel("language"), + FieldPanel("rich_text"), + FieldPanel("plain_text"), + ] + + class Meta: + abstract = True + + +class LanguageFallbackManager(models.Manager): + def get_object_in_preferred_language(self, language): + result = self.filter(language=language) + if result: + return result + + language_order = ["pt", "es", "en"] + langs = self.all().values_list("language", flat=True) + languages = Language.objects.filter(id__in=langs) + + order = [ + When(code2=lang, then=Value(i)) for i, lang in enumerate(language_order) + ] + ordered_languages = languages.annotate( + language_order=Case( + *order, default=Value(len(language_order)), output_field=IntegerField() + ) + ).order_by("language_order") + + for lang in ordered_languages: + result = self.filter(language=lang) + if result: + return result + return None + + +class RichTextWithLanguage(models.Model): + rich_text = RichTextField(_("Rich Text"), null=True, blank=True) + language = models.ForeignKey( + Language, + on_delete=models.SET_NULL, + verbose_name=_("Language"), + null=True, + blank=True, + ) + + panels = [ + AutocompletePanel("language"), + FieldPanel("rich_text"), + ] + + objects = LanguageFallbackManager() + + class Meta: + abstract = True + + +class FlexibleDate(models.Model): + year = models.IntegerField(_("Year"), null=True, blank=True) + month = models.IntegerField(_("Month"), null=True, blank=True) + day = models.IntegerField(_("Day"), null=True, blank=True) + + def __unicode__(self): + return "%s/%s/%s" % (self.year, self.month, self.day) + + def __str__(self): + return "%s/%s/%s" % (self.year, self.month, self.day) + + @property + def data(self): + return dict( + date__year=self.year, + date__month=self.month, + date__day=self.day, + ) + + +class License(CommonControlField): + license_type = models.CharField(max_length=16, null=True, blank=True) + + autocomplete_search_field = "license_type" + + def autocomplete_label(self): + return str(self) + + panels = [ + FieldPanel("license_type"), + ] + + class Meta: + unique_together = [("license_type",)] + verbose_name = _("License") + verbose_name_plural = _("Licenses") + indexes = [ + models.Index( + fields=[ + "license_type", + ] + ), + ] + + def __unicode__(self): + return self.license_type or "" + + def __str__(self): + return self.license_type or "" + + @classmethod + def get(cls, license_type): + if not license_type: + raise ValueError("License.get requires license_type parameter") + try: + return cls.objects.get(license_type__iexact=license_type) + except cls.MultipleObjectsReturned: + return cls.objects.filter(license_type__iexact=license_type).first() + + @classmethod + def create(cls, user, license_type=None): + try: + obj = cls() + obj.creator = user + obj.license_type = license_type + obj.save() + return obj + except IntegrityError: + return cls.get(license_type=license_type) + + @classmethod + def create_or_update(cls, user, license_type=None): + try: + return cls.get(license_type=license_type) + except cls.DoesNotExist: + return cls.create(user, license_type) + + +class LicenseStatement(CommonControlField): + url = models.CharField(max_length=255, null=True, blank=True) + license_p = RichTextField(null=True, blank=True) + language = models.ForeignKey( + Language, on_delete=models.SET_NULL, null=True, blank=True + ) + + panels = [ + FieldPanel("url"), + FieldPanel("license_p"), + AutocompletePanel("language"), + ] + + autocomplete_search_field = "url" + + def autocomplete_label(self): + return str(self) + + class Meta: + unique_together = [("url", "license_p", "language")] + verbose_name = _("License Statement") + verbose_name_plural = _("License Statements") + indexes = [ + models.Index(fields=["url"]), + ] + + def __unicode__(self): + return str(self) + + def __str__(self): + return self.url or "" + + @staticmethod + def parse_url(url): + """ + Parse Creative Commons license URL. + + Examples: + - https://creativecommons.org/licenses/by/4.0/ + - https://creativecommons.org/licenses/by-nc/3.0/br/ + """ + if not url: + return {} + + url = url.lower().rstrip("/") + url_parts = [p for p in url.split("/") if p] + + if not url_parts: + return {} + + license_types = dict(choices.LICENSE_TYPES) + + for i, part in enumerate(url_parts): + if part not in license_types: + continue + + license_type = part + remaining = url_parts[i + 1:] + license_version = None + + if remaining: + version_candidate = remaining[0] + if all(c.isdigit() or c == "." for c in version_candidate): + license_version = version_candidate + + return { + "license_type": license_type, + "license_version": license_version, + } + + return {} + + +class FileWithLang(models.Model): + file = models.ForeignKey( + "wagtaildocs.Document", + null=True, + blank=True, + on_delete=models.SET_NULL, + verbose_name=_("File"), + help_text="", + related_name="+", + ) + + language = models.ForeignKey( + Language, + on_delete=models.SET_NULL, + verbose_name=_("Language"), + null=True, + blank=True, + ) + + panels = [ + AutocompletePanel("language"), + FieldPanel("file"), + ] + + @property + def filename(self): + return os.path.basename(self.file.name) + class Meta: abstract = True \ No newline at end of file diff --git a/core/tests.py b/core/tests.py new file mode 100644 index 0000000..7f06ffb --- /dev/null +++ b/core/tests.py @@ -0,0 +1,234 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase + +from core import choices +from core.models import ( + FileWithLang, + FlexibleDate, + Gender, + Language, + LanguageFallbackManager, + License, + LicenseStatement, + RichTextWithLanguage, + TextLanguageMixin, + TextWithLang, +) +from core.utils.utils import language_iso + +User = get_user_model() + + +class LanguageIsoTest(TestCase): + def test_language_iso_normalizes_code(self): + self.assertEqual(language_iso("pt"), "pt") + + def test_language_iso_splits_on_hyphen(self): + self.assertEqual(language_iso("pt-BR"), "pt") + + def test_language_iso_splits_on_underscore(self): + self.assertEqual(language_iso("pt_BR"), "pt") + + def test_language_iso_empty_string(self): + self.assertEqual(language_iso(""), "") + + def test_language_iso_none(self): + self.assertEqual(language_iso(None), "") + + def test_language_iso_invalid(self): + self.assertEqual(language_iso("xyz123"), "") + + +class GenderModelTest(TestCase): + def setUp(self): + self.user = User.objects.create_user( + username="testuser", password="testpass123" + ) + + def test_create_or_update_creates_new(self): + gender = Gender.create_or_update(self.user, code="M", gender="Male") + self.assertIsNotNone(gender) + self.assertEqual(gender.code, "M") + self.assertEqual(gender.gender, "Male") + + def test_create_or_update_returns_existing(self): + g1 = Gender.create_or_update(self.user, code="F", gender="Female") + g2 = Gender.create_or_update(self.user, code="F", gender="Female") + self.assertEqual(g1.pk, g2.pk) + + def test_unique_together(self): + Gender.create_or_update(self.user, code="M", gender="Male") + self.assertEqual(Gender.objects.filter(code="M", gender="Male").count(), 1) + + def test_str(self): + gender = Gender.create_or_update(self.user, code="M", gender="Male") + self.assertEqual(str(gender), "Male") + + def test_load(self): + Gender.load(self.user) + self.assertEqual(Gender.objects.count(), len(choices.GENDER_CHOICES)) + + +class LanguageModelTest(TestCase): + def setUp(self): + self.user = User.objects.create_user( + username="testuser", password="testpass123" + ) + + def test_get_or_create_creates_new(self): + lang = Language.get_or_create(name="English", code2="en", creator=self.user) + self.assertIsNotNone(lang) + self.assertEqual(lang.code2, "en") + self.assertEqual(lang.name, "English") + + def test_get_or_create_normalizes_code2(self): + lang = Language.get_or_create(name="Portuguese", code2="pt-BR", creator=self.user) + self.assertEqual(lang.code2, "pt") + + def test_get_or_create_returns_existing(self): + l1 = Language.get_or_create(name="English", code2="en", creator=self.user) + l2 = Language.get_or_create(name="English", code2="en", creator=self.user) + self.assertEqual(l1.pk, l2.pk) + + def test_load_populates_when_empty(self): + Language.load(self.user) + self.assertGreater(Language.objects.count(), 0) + + def test_load_does_not_duplicate(self): + Language.load(self.user) + count1 = Language.objects.count() + Language.load(self.user) + count2 = Language.objects.count() + self.assertEqual(count1, count2) + + def test_str(self): + lang = Language.get_or_create(name="English", code2="en", creator=self.user) + self.assertEqual(str(lang), "English | en") + + def test_str_none(self): + lang = Language() + self.assertEqual(str(lang), "None") + + +class LicenseModelTest(TestCase): + def setUp(self): + self.user = User.objects.create_user( + username="testuser", password="testpass123" + ) + + def test_get_uses_iexact(self): + License.create(self.user, license_type="by") + lic = License.get("BY") + self.assertEqual(lic.license_type, "by") + + def test_create_or_update(self): + l1 = License.create_or_update(self.user, license_type="by-nc") + l2 = License.create_or_update(self.user, license_type="by-nc") + self.assertEqual(l1.pk, l2.pk) + + def test_unique_together(self): + License.create(self.user, license_type="by") + self.assertEqual(License.objects.filter(license_type="by").count(), 1) + + def test_str(self): + lic = License.create(self.user, license_type="by") + self.assertEqual(str(lic), "by") + + def test_get_raises_on_empty(self): + with self.assertRaises(ValueError): + License.get(None) + + +class LicenseStatementModelTest(TestCase): + def test_parse_url_extracts_type_and_version(self): + result = LicenseStatement.parse_url( + "https://creativecommons.org/licenses/by/4.0/" + ) + self.assertEqual(result["license_type"], "by") + self.assertEqual(result["license_version"], "4.0") + + def test_parse_url_by_nc(self): + result = LicenseStatement.parse_url( + "https://creativecommons.org/licenses/by-nc/3.0/br/" + ) + self.assertEqual(result["license_type"], "by-nc") + self.assertEqual(result["license_version"], "3.0") + + def test_parse_url_empty(self): + result = LicenseStatement.parse_url("") + self.assertEqual(result, {}) + + def test_parse_url_none(self): + result = LicenseStatement.parse_url(None) + self.assertEqual(result, {}) + + def test_parse_url_invalid(self): + result = LicenseStatement.parse_url("https://example.com/") + self.assertEqual(result, {}) + + def test_str(self): + ls = LicenseStatement(url="https://creativecommons.org/licenses/by/4.0/") + self.assertEqual(str(ls), "https://creativecommons.org/licenses/by/4.0/") + + +class FlexibleDateModelTest(TestCase): + def test_data_property(self): + fd = FlexibleDate(year=2024, month=3, day=15) + expected = { + "date__year": 2024, + "date__month": 3, + "date__day": 15, + } + self.assertEqual(fd.data, expected) + + def test_data_property_with_none(self): + fd = FlexibleDate(year=2024) + expected = { + "date__year": 2024, + "date__month": None, + "date__day": None, + } + self.assertEqual(fd.data, expected) + + def test_str(self): + fd = FlexibleDate(year=2024, month=3, day=15) + self.assertEqual(str(fd), "2024/3/15") + + +class AbstractModelsTest(TestCase): + def test_text_with_lang_is_abstract(self): + self.assertTrue(TextWithLang._meta.abstract) + + def test_text_language_mixin_is_abstract(self): + self.assertTrue(TextLanguageMixin._meta.abstract) + + def test_rich_text_with_language_is_abstract(self): + self.assertTrue(RichTextWithLanguage._meta.abstract) + + def test_file_with_lang_is_abstract(self): + self.assertTrue(FileWithLang._meta.abstract) + + def test_flexible_date_is_not_abstract(self): + self.assertFalse(FlexibleDate._meta.abstract) + + def test_gender_is_not_abstract(self): + self.assertFalse(Gender._meta.abstract) + + def test_language_is_not_abstract(self): + self.assertFalse(Language._meta.abstract) + + def test_license_is_not_abstract(self): + self.assertFalse(License._meta.abstract) + + def test_license_statement_is_not_abstract(self): + self.assertFalse(LicenseStatement._meta.abstract) + + +class LanguageFallbackManagerTest(TestCase): + def test_manager_is_language_fallback_manager(self): + # Abstract models pass their managers to concrete subclasses; + # verify the declared default manager type on the abstract model + managers = [m for m in RichTextWithLanguage._meta.managers] + self.assertTrue( + any(isinstance(m, LanguageFallbackManager) for m in managers) + ) diff --git a/core/utils/utils.py b/core/utils/utils.py new file mode 100644 index 0000000..68e5233 --- /dev/null +++ b/core/utils/utils.py @@ -0,0 +1,10 @@ +import re + +from langcodes import standardize_tag, tag_is_valid + + +def language_iso(code): + code = re.split(r"-|_", code)[0] if code else "" + if tag_is_valid(code): + return standardize_tag(code) + return "" diff --git a/requirements/base.txt b/requirements/base.txt index 5c1bac1..e511fd9 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -42,3 +42,7 @@ git+https://git@github.com/scieloorg/packtools@4.12.6#egg=packtools # Langdetect # ------------------------------------------------------------------------------ langdetect~=1.0.9 + +# Langcodes +# ------------------------------------------------------------------------------ +langcodes==3.5.1