diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 87849730..8a30a85e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,6 @@ repos: - id: end-of-file-fixer - id: check-yaml - id: debug-statements - - id: double-quote-string-fixer - id: name-tests-test - repo: https://github.com/asottile/setup-cfg-fmt rev: v2.5.0 @@ -23,21 +22,13 @@ repos: hooks: - id: pyupgrade args: [--py39-plus] -- repo: https://github.com/hhatto/autopep8 - rev: v2.1.0 - hooks: - - id: autopep8 -- repo: https://github.com/PyCQA/flake8 - rev: 7.0.0 - hooks: - - id: flake8 - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.9.0 hooks: - id: mypy additional_dependencies: [types-all] - exclude: ^testing/resources/ + exclude: ^(settings/|backend/) - repo: https://github.com/psf/black - rev: 22.10.0 + rev: 24.3.0 hooks: - id: black diff --git a/backend/migrations/0031_user_language.py b/backend/migrations/0031_user_language.py new file mode 100644 index 00000000..73fedc77 --- /dev/null +++ b/backend/migrations/0031_user_language.py @@ -0,0 +1,20 @@ +# Generated by Django 5.0.4 on 2024-04-04 21:13 +from __future__ import annotations + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("backend", "0030_alter_invoice_items"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="language", + field=models.CharField(choices=[("en", "English"), ("ru", "Russian")], default="en-us", max_length=10), + ), + ] diff --git a/backend/models.py b/backend/models.py index cd756a30..5b6e376a 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,13 +1,22 @@ +from __future__ import annotations + from datetime import datetime from decimal import Decimal -from typing import Optional, NoReturn, Union, Literal +from typing import Literal +from typing import NoReturn +from typing import Optional +from typing import Union from uuid import uuid4 -from django.contrib.auth.hashers import make_password, check_password -from django.contrib.auth.models import UserManager, AbstractUser, AnonymousUser +from django.contrib.auth.hashers import check_password +from django.contrib.auth.hashers import make_password +from django.contrib.auth.models import AbstractUser +from django.contrib.auth.models import AnonymousUser +from django.contrib.auth.models import UserManager from django.core.validators import MaxValueValidator from django.db import models -from django.db.models import Count, QuerySet +from django.db.models import Count +from django.db.models import QuerySet from django.utils import timezone from django.utils.crypto import get_random_string from shortuuid.django_fields import ShortUUIDField @@ -37,7 +46,7 @@ def get_queryset(self): super() .get_queryset() .select_related("user_profile", "logged_in_as_team") - .annotate(notification_count=((Count("user_notifications")))) + .annotate(notification_count=(Count("user_notifications"))) ) @@ -55,6 +64,7 @@ class Role(models.TextChoices): TESTER = "TESTER", "Tester" role = models.CharField(max_length=10, choices=Role.choices, default=Role.USER) + language = models.CharField(max_length=10, choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE) class CustomUserMiddleware: @@ -82,8 +92,10 @@ class ServiceTypes(models.TextChoices): CREATE_ACCOUNT = "create_account", "Create Account" RESET_PASSWORD = "reset_password", "Reset Password" - uuid = models.UUIDField(default=uuid4, editable=False, unique=True) # This is the public identifier - token = models.TextField(default=RandomCode, editable=False) # This is the private token (should be hashed) + # This is the public identifier + uuid = models.UUIDField(default=uuid4, editable=False, unique=True) + # This is the private token (should be hashed) + token = models.TextField(default=RandomCode, editable=False) user = models.ForeignKey(User, on_delete=models.CASCADE) created = models.DateTimeField(auto_now_add=True) @@ -587,7 +599,7 @@ class Meta: def __str__(self): return self.name - def get_quota_limit(self, user: User, quota_limit: Optional["QuotaLimit"] = None): + def get_quota_limit(self, user: User, quota_limit: QuotaLimit | None = None): try: if quota_limit: user_quota_override = quota_limit @@ -607,14 +619,14 @@ def get_period_usage(self, user: User): else: return "Not available" - def strict_goes_above_limit(self, user: User, extra: Optional[str | int] = None) -> bool: + def strict_goes_above_limit(self, user: User, extra: str | int | None = None) -> bool: current = self.strict_get_quotas(user, extra) current = current.count() if current != "Not Available" else None return current >= self.get_quota_limit(user) if current else False def strict_get_quotas( - self, user: User, extra: Optional[str | int] = None, quota_limit: Optional["QuotaLimit"] = None - ) -> Union['QuerySet["QuotaUsage"]', Literal["Not Available"]]: + self, user: User, extra: str | int | None = None, quota_limit: QuotaLimit | None = None + ) -> QuerySet[QuotaUsage] | Literal["Not Available"]: """ Gets all usages of a quota :return: QuerySet of quota usages OR "Not Available" if utilisation isn't available (e.g. per invoice you can't get in total) @@ -640,7 +652,7 @@ def strict_get_quotas( return current @classmethod - def delete_quota_usage(cls, quota_limit: Union[str, "QuotaLimit"], user: User, extra, timestamp=None) -> NoReturn: + def delete_quota_usage(cls, quota_limit: str | QuotaLimit, user: User, extra, timestamp=None) -> NoReturn: quota_limit = cls.objects.get(slug=quota_limit) if isinstance(quota_limit, str) else quota_limit all_usages = quota_limit.strict_get_quotas(user, extra) @@ -699,7 +711,7 @@ def __str__(self): return f"{self.user} quota usage for {self.quota_limit_id}" @classmethod - def create_str(cls, user: User, limit: str | QuotaLimit, extra_data: Optional[str | int] = None): + def create_str(cls, user: User, limit: str | QuotaLimit, extra_data: str | int | None = None): try: quota_limit = limit if isinstance(limit, QuotaLimit) else QuotaLimit.objects.get(slug=limit) except QuotaLimit.DoesNotExist: diff --git a/docs/contributing/translations.md b/docs/contributing/translations.md index eb2436b9..b7d79fa7 100644 --- a/docs/contributing/translations.md +++ b/docs/contributing/translations.md @@ -1,2 +1,73 @@ -Currently we have no translations or a translation framework. If you have ideas for how we could implement translations feel -free to make an issue on github and suggest it for our docs! +Currently we support 2 languages: English and Russian. + +## Translate non-translated strings +### Mark string as translatable +To make strings translatable we should use function `gettext_lazy`. +In python code: +```python +from django.utils.translation import gettext_lazy as _ + +print(_("Hello")) +``` + +In Django templates you may use `trans` from `i18n` module: +```html +{% load i18n %} + + + +
{% trans "Hello" %} + + +``` + +### Scrape all messages to translate into *.po files +Run following command: +```shell +$ django-admin makemessages --all --ignore=env +``` + +Execute this command every time you mark new strings for translation or add new strings. + +### Translate strings +Open file of the language you want to translate to (e.g. Russian): `locale/ru/LC_MESSAGES/django.po` +Translate every string (value in `msgstr`) +```python +#: frontend/templates/pages/index.html:24 +msgid "Dashboard" +msgstr "Обзор" +``` + +### Compile strings for Django server +Run following command: +```shell +$ django-admin compilemessages --ignore=env +``` + +This command will generate `*.mo` files in `locale` folder. No need to include it into Git since it's a generated content (ecxcluded via .gitignore). +Execute this command every time you translate strings in `*.po` files. + +## Adding new languages +1. Add new language into the `LANGUAGES` variable in `settings.py`: + ```python + LANGUAGES = ( + ("en", _("English")), + ("ru", _("Russian")), + ("fr", _("French")) # <- e.g. this is a new language + ) + ``` +2. Create new folder for you language + ```shell + $ mkdir locale/fr + ``` +3. Collect new strings for your new language + ```shell + $ django-admin makemessages --all --ignore=env + ``` +4. Translate strings in `*.po` files + 1. Open in your favourite text editor + 2. Or use GUI tools for translations (e.g. https://poedit.net/) +5. Compile strings into `*.mo` files + ```shell + $ django-admin compilemessages --ignore=env + ``` diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index ab7a6fff..fa96b95e 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -39,20 +39,24 @@ git clone [copied fork url] pip install poetry poetry install --no-root ``` - 2. Setup a database (we suggest using sqlite so there's no installation!) - 3. Migrate the database - ```shell - python manage.py migrate - ``` - 4. Create an administrator account - ```shell - python manage.py createsuperuser - ``` +2. Setup a database (we suggest using sqlite so there's no installation!) +3. Compile translations + ```shell + django-admin compilemessages --ignore=env + ``` +4. Migrate the database + ```shell + python manage.py migrate + ``` +5. Create an administrator account + ```shell + python manage.py createsuperuser + ``` - 5. Run the application - ```shell - python manage.py runserver - ``` +6. Run the application + ```shell + python manage.py runserver + ``` ## Setup the frontend diff --git a/frontend/templates/pages/index.html b/frontend/templates/pages/index.html index e59852ec..bb8f4914 100644 --- a/frontend/templates/pages/index.html +++ b/frontend/templates/pages/index.html @@ -1,4 +1,5 @@ {% load static %} +{% load i18n %} {% include 'base/_head.html' %} @@ -12,7 +13,7 @@
My Finances + href="/">{% trans "My Finances" %}
@@ -20,12 +21,12 @@ @@ -35,8 +36,8 @@
diff --git a/infrastructure/backend/scripts/entrypoint.sh b/infrastructure/backend/scripts/entrypoint.sh index dcdafafa..40d1aaf7 100644 --- a/infrastructure/backend/scripts/entrypoint.sh +++ b/infrastructure/backend/scripts/entrypoint.sh @@ -1,5 +1,7 @@ #!/bin/sh python3 manage.py makemigrations --no-input && python3 manage.py migrate --no-input && python3 manage.py collectstatic --no-input +# compile translations +django-admin compilemessages --ignore=env -gunicorn settings.wsgi:application --bind 0.0.0.0:9012 --workers 2 \ No newline at end of file +gunicorn settings.wsgi:application --bind 0.0.0.0:9012 --workers 2 diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po new file mode 100644 index 00000000..8ae92439 --- /dev/null +++ b/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,55 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-04-04 17:58-0300\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: frontend/templates/pages/index.html:16 +msgid "My Finances" +msgstr "" + +#: frontend/templates/pages/index.html:24 +msgid "Dashboard" +msgstr "" + +#: frontend/templates/pages/index.html:25 +msgid "Logout" +msgstr "" + +#: frontend/templates/pages/index.html:28 +msgid "Register" +msgstr "" + +#: frontend/templates/pages/index.html:29 +msgid "Login" +msgstr "" + +#: frontend/templates/pages/index.html:39 +msgid "We're still in development!" +msgstr "" + +#: frontend/templates/pages/index.html:40 +msgid "Go to dashboard" +msgstr "" + +#: settings/settings.py:239 +msgid "English" +msgstr "" + +#: settings/settings.py:240 +msgid "Russian" +msgstr "" diff --git a/locale/ru/LC_MESSAGES/django.po b/locale/ru/LC_MESSAGES/django.po new file mode 100644 index 00000000..08ace123 --- /dev/null +++ b/locale/ru/LC_MESSAGES/django.po @@ -0,0 +1,54 @@ +msgid "" +msgstr "" +"Project-Id-Version: myfinances\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-04-04 17:58-0300\n" +"PO-Revision-Date: 2024-04-04 21:06\n" +"Last-Translator: \n" +"Language-Team: Russian\n" +"Language: ru_RU\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n" +"X-Crowdin-Project: myfinances\n" +"X-Crowdin-Project-ID: 663218\n" +"X-Crowdin-Language: ru\n" +"X-Crowdin-File: django.po\n" +"X-Crowdin-File-ID: 4\n" + +#: frontend/templates/pages/index.html:16 +msgid "My Finances" +msgstr "My Finances" + +#: frontend/templates/pages/index.html:24 +msgid "Dashboard" +msgstr "Обзор" + +#: frontend/templates/pages/index.html:25 +msgid "Logout" +msgstr "Выход" + +#: frontend/templates/pages/index.html:28 +msgid "Register" +msgstr "Регистрация" + +#: frontend/templates/pages/index.html:29 +msgid "Login" +msgstr "Вход" + +#: frontend/templates/pages/index.html:39 +msgid "We're still in development!" +msgstr "Мы все еще разрабатываем!" + +#: frontend/templates/pages/index.html:40 +msgid "Go to dashboard" +msgstr "Перейти на главную страницу" + +#: settings/settings.py:239 +msgid "English" +msgstr "Английский" + +#: settings/settings.py:240 +msgid "Russian" +msgstr "Русский" diff --git a/settings/settings.py b/settings/settings.py index 17b026d1..bb65a17e 100644 --- a/settings/settings.py +++ b/settings/settings.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import base64 import logging import mimetypes @@ -7,12 +9,11 @@ from django.contrib.messages import constants as messages from django.contrib.staticfiles.storage import FileSystemStorage +from django.utils.translation import gettext_lazy as _ from storages.backends.s3 import S3Storage from .helpers import get_var -# from backend.utils import appconfig - DEBUG = True if get_var("DEBUG") in ["True", "true", "TRUE", True] else False SITE_URL = get_var("SITE_URL", default="http://127.0.0.1:8000") @@ -176,6 +177,7 @@ MIDDLEWARE = [ "backend.middleware.HealthCheckMiddleware", + "django.middleware.locale.LocaleMiddleware", "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "backend.middleware.LastVisitedMiddleware", @@ -233,9 +235,19 @@ LANGUAGE_CODE = "en-us" +LANGUAGES = ( + ("en", _("English")), + ("ru", _("Russian")), +) + +LOCALE_PATHS = [ + BASE_DIR / "locale/", +] + TIME_ZONE = "UTC" USE_I18N = True +USE_L10N = True USE_TZ = True @@ -346,8 +358,8 @@ class CustomPrivateMediaStorage(S3Storage): MEDIA_ROOT = os.path.join(BASE_DIR, "media") DEFAULT_FILE_STORAGE = "django.core.files.storage.FileSystemStorage" - class CustomPublicMediaStorage(FileSystemStorage): # This overrides the AWS version - ... + # This overrides the AWS version + class CustomPublicMediaStorage(FileSystemStorage): ... AWS_MEDIA_PRIVATE_ENABLED = get_var("AWS_MEDIA_PRIVATE_ENABLED", default=False).lower() == "true" @@ -356,8 +368,8 @@ class CustomPublicMediaStorage(FileSystemStorage): # This overrides the AWS ver PRIVATE_FILE_STORAGE = "settings.settings.CustomPrivateMediaStorage" else: - class CustomPrivateMediaStorage(FileSystemStorage): # This overrides the AWS version - ... + # This overrides the AWS version + class CustomPrivateMediaStorage(FileSystemStorage): ... PRIVATE_FILE_STORAGE = "django.core.files.storage.FileSystemStorage"