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 @@
@@ -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"