mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-04-01 06:53:18 +02:00
Enable filtering and sorting on all API views
This commit is contained in:
@@ -434,14 +434,16 @@ REST_FRAMEWORK = {
|
||||
"apps.api.permissions.NotInDemoMode",
|
||||
"rest_framework.permissions.DjangoModelPermissions",
|
||||
],
|
||||
"DEFAULT_PAGINATION_CLASS": "apps.api.custom.pagination.CustomPageNumberPagination",
|
||||
'DEFAULT_FILTER_BACKENDS': [
|
||||
'django_filters.rest_framework.DjangoFilterBackend',
|
||||
'rest_framework.filters.OrderingFilter',
|
||||
],
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||
'rest_framework.authentication.BasicAuthentication',
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
'rest_framework.authentication.TokenAuthentication',
|
||||
],
|
||||
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
||||
"PAGE_SIZE": 100,
|
||||
"DEFAULT_PAGINATION_CLASS": "apps.api.custom.pagination.CustomPageNumberPagination",
|
||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# Import all test classes for Django test discovery
|
||||
from .test_imports import *
|
||||
from .test_accounts import *
|
||||
|
||||
from .test_data_isolation import *
|
||||
from .test_shared_access import *
|
||||
|
||||
719
app/apps/api/tests/test_data_isolation.py
Normal file
719
app/apps/api/tests/test_data_isolation.py
Normal file
@@ -0,0 +1,719 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase, override_settings
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.accounts.models import Account, AccountGroup
|
||||
from apps.currencies.models import Currency
|
||||
from apps.dca.models import DCAStrategy, DCAEntry
|
||||
from apps.transactions.models import (
|
||||
Transaction,
|
||||
TransactionCategory,
|
||||
TransactionTag,
|
||||
TransactionEntity,
|
||||
InstallmentPlan,
|
||||
RecurringTransaction,
|
||||
)
|
||||
|
||||
|
||||
ACCESS_DENIED_CODES = [status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND]
|
||||
|
||||
|
||||
@override_settings(
|
||||
STORAGES={
|
||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||
"staticfiles": {
|
||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||
},
|
||||
},
|
||||
WHITENOISE_AUTOREFRESH=True,
|
||||
)
|
||||
class AccountDataIsolationTests(TestCase):
|
||||
"""Tests to ensure users cannot access other users' accounts."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data with two distinct users."""
|
||||
User = get_user_model()
|
||||
|
||||
# User 1 - the requester
|
||||
self.user1 = User.objects.create_user(
|
||||
email="user1@test.com", password="testpass123"
|
||||
)
|
||||
self.client1 = APIClient()
|
||||
self.client1.force_authenticate(user=self.user1)
|
||||
|
||||
# User 2 - owner of data that user1 should NOT access
|
||||
self.user2 = User.objects.create_user(
|
||||
email="user2@test.com", password="testpass123"
|
||||
)
|
||||
self.client2 = APIClient()
|
||||
self.client2.force_authenticate(user=self.user2)
|
||||
|
||||
# Shared currency
|
||||
self.currency = Currency.objects.create(
|
||||
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
|
||||
)
|
||||
|
||||
# User 1's account
|
||||
self.user1_account_group = AccountGroup.all_objects.create(
|
||||
name="User1 Group", owner=self.user1
|
||||
)
|
||||
self.user1_account = Account.all_objects.create(
|
||||
name="User1 Account",
|
||||
group=self.user1_account_group,
|
||||
currency=self.currency,
|
||||
owner=self.user1,
|
||||
)
|
||||
|
||||
# User 2's account (private, should be invisible to user1)
|
||||
self.user2_account_group = AccountGroup.all_objects.create(
|
||||
name="User2 Group", owner=self.user2
|
||||
)
|
||||
self.user2_account = Account.all_objects.create(
|
||||
name="User2 Account",
|
||||
group=self.user2_account_group,
|
||||
currency=self.currency,
|
||||
owner=self.user2,
|
||||
)
|
||||
|
||||
def test_user_cannot_see_other_users_accounts_in_list(self):
|
||||
"""GET /api/accounts/ should only return user's own accounts."""
|
||||
response = self.client1.get("/api/accounts/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
# User1 should only see their own account
|
||||
account_ids = [acc["id"] for acc in response.data["results"]]
|
||||
self.assertIn(self.user1_account.id, account_ids)
|
||||
self.assertNotIn(self.user2_account.id, account_ids)
|
||||
|
||||
def test_user_cannot_access_other_users_account_detail(self):
|
||||
"""GET /api/accounts/{id}/ should deny access to other user's account."""
|
||||
response = self.client1.get(f"/api/accounts/{self.user2_account.id}/")
|
||||
|
||||
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
|
||||
|
||||
def test_user_cannot_modify_other_users_account(self):
|
||||
"""PATCH on other user's account should deny access."""
|
||||
response = self.client1.patch(
|
||||
f"/api/accounts/{self.user2_account.id}/",
|
||||
{"name": "Hacked Account"},
|
||||
)
|
||||
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
|
||||
|
||||
# Verify account name wasn't changed
|
||||
self.user2_account.refresh_from_db()
|
||||
self.assertEqual(self.user2_account.name, "User2 Account")
|
||||
|
||||
def test_user_cannot_delete_other_users_account(self):
|
||||
"""DELETE on other user's account should deny access."""
|
||||
response = self.client1.delete(f"/api/accounts/{self.user2_account.id}/")
|
||||
|
||||
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
|
||||
|
||||
# Verify account still exists
|
||||
self.assertTrue(Account.all_objects.filter(id=self.user2_account.id).exists())
|
||||
|
||||
def test_user_cannot_get_balance_of_other_users_account(self):
|
||||
"""Balance action on other user's account should deny access."""
|
||||
response = self.client1.get(f"/api/accounts/{self.user2_account.id}/balance/")
|
||||
|
||||
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
|
||||
|
||||
def test_user_can_access_own_account(self):
|
||||
"""User can access their own account normally."""
|
||||
response = self.client1.get(f"/api/accounts/{self.user1_account.id}/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["name"], "User1 Account")
|
||||
|
||||
|
||||
@override_settings(
|
||||
STORAGES={
|
||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||
"staticfiles": {
|
||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||
},
|
||||
},
|
||||
WHITENOISE_AUTOREFRESH=True,
|
||||
)
|
||||
class AccountGroupDataIsolationTests(TestCase):
|
||||
"""Tests to ensure users cannot access other users' account groups."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data with two distinct users."""
|
||||
User = get_user_model()
|
||||
|
||||
self.user1 = User.objects.create_user(
|
||||
email="user1@test.com", password="testpass123"
|
||||
)
|
||||
self.client1 = APIClient()
|
||||
self.client1.force_authenticate(user=self.user1)
|
||||
|
||||
self.user2 = User.objects.create_user(
|
||||
email="user2@test.com", password="testpass123"
|
||||
)
|
||||
|
||||
# User 1's account group
|
||||
self.user1_group = AccountGroup.all_objects.create(
|
||||
name="User1 Group", owner=self.user1
|
||||
)
|
||||
|
||||
# User 2's account group
|
||||
self.user2_group = AccountGroup.all_objects.create(
|
||||
name="User2 Group", owner=self.user2
|
||||
)
|
||||
|
||||
def test_user_cannot_see_other_users_account_groups(self):
|
||||
"""GET /api/account-groups/ should only return user's own groups."""
|
||||
response = self.client1.get("/api/account-groups/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
group_ids = [grp["id"] for grp in response.data["results"]]
|
||||
self.assertIn(self.user1_group.id, group_ids)
|
||||
self.assertNotIn(self.user2_group.id, group_ids)
|
||||
|
||||
def test_user_cannot_access_other_users_account_group_detail(self):
|
||||
"""GET /api/account-groups/{id}/ should deny access to other user's group."""
|
||||
response = self.client1.get(f"/api/account-groups/{self.user2_group.id}/")
|
||||
|
||||
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
|
||||
|
||||
def test_user_cannot_modify_other_users_account_group(self):
|
||||
"""PATCH on other user's account group should deny access."""
|
||||
response = self.client1.patch(
|
||||
f"/api/account-groups/{self.user2_group.id}/",
|
||||
{"name": "Hacked Group"},
|
||||
)
|
||||
|
||||
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
|
||||
|
||||
self.user2_group.refresh_from_db()
|
||||
self.assertEqual(self.user2_group.name, "User2 Group")
|
||||
|
||||
def test_user_cannot_delete_other_users_account_group(self):
|
||||
"""DELETE on other user's account group should deny access."""
|
||||
response = self.client1.delete(f"/api/account-groups/{self.user2_group.id}/")
|
||||
|
||||
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
|
||||
|
||||
self.assertTrue(
|
||||
AccountGroup.all_objects.filter(id=self.user2_group.id).exists()
|
||||
)
|
||||
|
||||
|
||||
@override_settings(
|
||||
STORAGES={
|
||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||
"staticfiles": {
|
||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||
},
|
||||
},
|
||||
WHITENOISE_AUTOREFRESH=True,
|
||||
)
|
||||
class TransactionDataIsolationTests(TestCase):
|
||||
"""Tests to ensure users cannot access other users' transactions."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data with transactions for two distinct users."""
|
||||
User = get_user_model()
|
||||
|
||||
self.user1 = User.objects.create_user(
|
||||
email="user1@test.com", password="testpass123"
|
||||
)
|
||||
self.client1 = APIClient()
|
||||
self.client1.force_authenticate(user=self.user1)
|
||||
|
||||
self.user2 = User.objects.create_user(
|
||||
email="user2@test.com", password="testpass123"
|
||||
)
|
||||
|
||||
self.currency = Currency.objects.create(
|
||||
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
|
||||
)
|
||||
|
||||
# User 1's account and transaction
|
||||
self.user1_account = Account.all_objects.create(
|
||||
name="User1 Account", currency=self.currency, owner=self.user1
|
||||
)
|
||||
self.user1_transaction = Transaction.userless_all_objects.create(
|
||||
account=self.user1_account,
|
||||
type=Transaction.Type.INCOME,
|
||||
amount=Decimal("100.00"),
|
||||
is_paid=True,
|
||||
date=date(2025, 1, 1),
|
||||
description="User1 Income",
|
||||
owner=self.user1,
|
||||
)
|
||||
|
||||
# User 2's account and transaction
|
||||
self.user2_account = Account.all_objects.create(
|
||||
name="User2 Account", currency=self.currency, owner=self.user2
|
||||
)
|
||||
self.user2_transaction = Transaction.userless_all_objects.create(
|
||||
account=self.user2_account,
|
||||
type=Transaction.Type.EXPENSE,
|
||||
amount=Decimal("50.00"),
|
||||
is_paid=True,
|
||||
date=date(2025, 1, 1),
|
||||
description="User2 Expense",
|
||||
owner=self.user2,
|
||||
)
|
||||
|
||||
def test_user_cannot_see_other_users_transactions_in_list(self):
|
||||
"""GET /api/transactions/ should only return user's own transactions."""
|
||||
response = self.client1.get("/api/transactions/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
transaction_ids = [t["id"] for t in response.data["results"]]
|
||||
self.assertIn(self.user1_transaction.id, transaction_ids)
|
||||
self.assertNotIn(self.user2_transaction.id, transaction_ids)
|
||||
|
||||
def test_user_cannot_access_other_users_transaction_detail(self):
|
||||
"""GET /api/transactions/{id}/ should deny access to other user's transaction."""
|
||||
response = self.client1.get(f"/api/transactions/{self.user2_transaction.id}/")
|
||||
|
||||
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
|
||||
|
||||
def test_user_cannot_modify_other_users_transaction(self):
|
||||
"""PATCH on other user's transaction should deny access."""
|
||||
response = self.client1.patch(
|
||||
f"/api/transactions/{self.user2_transaction.id}/",
|
||||
{"description": "Hacked Transaction"},
|
||||
)
|
||||
|
||||
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
|
||||
|
||||
self.user2_transaction.refresh_from_db()
|
||||
self.assertEqual(self.user2_transaction.description, "User2 Expense")
|
||||
|
||||
def test_user_cannot_delete_other_users_transaction(self):
|
||||
"""DELETE on other user's transaction should deny access."""
|
||||
response = self.client1.delete(
|
||||
f"/api/transactions/{self.user2_transaction.id}/"
|
||||
)
|
||||
|
||||
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
|
||||
|
||||
self.assertTrue(
|
||||
Transaction.userless_all_objects.filter(
|
||||
id=self.user2_transaction.id
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_user_cannot_create_transaction_in_other_users_account(self):
|
||||
"""POST /api/transactions/ with other user's account should fail."""
|
||||
response = self.client1.post(
|
||||
"/api/transactions/",
|
||||
{
|
||||
"account": self.user2_account.id,
|
||||
"type": "IN",
|
||||
"amount": "100.00",
|
||||
"date": "2025-01-15",
|
||||
"description": "Sneaky transaction",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
# Should deny access - 400 (validation error), 403, or 404
|
||||
self.assertIn(
|
||||
response.status_code,
|
||||
ACCESS_DENIED_CODES + [status.HTTP_400_BAD_REQUEST],
|
||||
)
|
||||
|
||||
|
||||
@override_settings(
|
||||
STORAGES={
|
||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||
"staticfiles": {
|
||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||
},
|
||||
},
|
||||
WHITENOISE_AUTOREFRESH=True,
|
||||
)
|
||||
class CategoryTagEntityIsolationTests(TestCase):
|
||||
"""Tests for isolation of categories, tags, and entities between users."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
User = get_user_model()
|
||||
|
||||
self.user1 = User.objects.create_user(
|
||||
email="user1@test.com", password="testpass123"
|
||||
)
|
||||
self.client1 = APIClient()
|
||||
self.client1.force_authenticate(user=self.user1)
|
||||
|
||||
self.user2 = User.objects.create_user(
|
||||
email="user2@test.com", password="testpass123"
|
||||
)
|
||||
|
||||
# User 1's categories, tags, entities
|
||||
self.user1_category = TransactionCategory.all_objects.create(
|
||||
name="User1 Category", owner=self.user1
|
||||
)
|
||||
self.user1_tag = TransactionTag.all_objects.create(
|
||||
name="User1 Tag", owner=self.user1
|
||||
)
|
||||
self.user1_entity = TransactionEntity.all_objects.create(
|
||||
name="User1 Entity", owner=self.user1
|
||||
)
|
||||
|
||||
# User 2's categories, tags, entities
|
||||
self.user2_category = TransactionCategory.all_objects.create(
|
||||
name="User2 Category", owner=self.user2
|
||||
)
|
||||
self.user2_tag = TransactionTag.all_objects.create(
|
||||
name="User2 Tag", owner=self.user2
|
||||
)
|
||||
self.user2_entity = TransactionEntity.all_objects.create(
|
||||
name="User2 Entity", owner=self.user2
|
||||
)
|
||||
|
||||
def test_user_cannot_see_other_users_categories(self):
|
||||
"""GET /api/categories/ should only return user's own categories."""
|
||||
response = self.client1.get("/api/categories/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
category_ids = [c["id"] for c in response.data["results"]]
|
||||
self.assertIn(self.user1_category.id, category_ids)
|
||||
self.assertNotIn(self.user2_category.id, category_ids)
|
||||
|
||||
def test_user_cannot_access_other_users_category_detail(self):
|
||||
"""GET /api/categories/{id}/ should deny access to other user's category."""
|
||||
response = self.client1.get(f"/api/categories/{self.user2_category.id}/")
|
||||
|
||||
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
|
||||
|
||||
def test_user_cannot_see_other_users_tags(self):
|
||||
"""GET /api/tags/ should only return user's own tags."""
|
||||
response = self.client1.get("/api/tags/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
tag_ids = [t["id"] for t in response.data["results"]]
|
||||
self.assertIn(self.user1_tag.id, tag_ids)
|
||||
self.assertNotIn(self.user2_tag.id, tag_ids)
|
||||
|
||||
def test_user_cannot_access_other_users_tag_detail(self):
|
||||
"""GET /api/tags/{id}/ should deny access to other user's tag."""
|
||||
response = self.client1.get(f"/api/tags/{self.user2_tag.id}/")
|
||||
|
||||
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
|
||||
|
||||
def test_user_cannot_see_other_users_entities(self):
|
||||
"""GET /api/entities/ should only return user's own entities."""
|
||||
response = self.client1.get("/api/entities/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
entity_ids = [e["id"] for e in response.data["results"]]
|
||||
self.assertIn(self.user1_entity.id, entity_ids)
|
||||
self.assertNotIn(self.user2_entity.id, entity_ids)
|
||||
|
||||
def test_user_cannot_access_other_users_entity_detail(self):
|
||||
"""GET /api/entities/{id}/ should deny access to other user's entity."""
|
||||
response = self.client1.get(f"/api/entities/{self.user2_entity.id}/")
|
||||
|
||||
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
|
||||
|
||||
def test_user_cannot_modify_other_users_category(self):
|
||||
"""PATCH on other user's category should deny access."""
|
||||
response = self.client1.patch(
|
||||
f"/api/categories/{self.user2_category.id}/",
|
||||
{"name": "Hacked Category"},
|
||||
)
|
||||
|
||||
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
|
||||
|
||||
def test_user_cannot_delete_other_users_tag(self):
|
||||
"""DELETE on other user's tag should deny access."""
|
||||
response = self.client1.delete(f"/api/tags/{self.user2_tag.id}/")
|
||||
|
||||
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
|
||||
|
||||
self.assertTrue(
|
||||
TransactionTag.all_objects.filter(id=self.user2_tag.id).exists()
|
||||
)
|
||||
|
||||
|
||||
@override_settings(
|
||||
STORAGES={
|
||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||
"staticfiles": {
|
||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||
},
|
||||
},
|
||||
WHITENOISE_AUTOREFRESH=True,
|
||||
)
|
||||
class DCADataIsolationTests(TestCase):
|
||||
"""Tests to ensure users cannot access other users' DCA strategies and entries."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
User = get_user_model()
|
||||
|
||||
self.user1 = User.objects.create_user(
|
||||
email="user1@test.com", password="testpass123"
|
||||
)
|
||||
self.client1 = APIClient()
|
||||
self.client1.force_authenticate(user=self.user1)
|
||||
|
||||
self.user2 = User.objects.create_user(
|
||||
email="user2@test.com", password="testpass123"
|
||||
)
|
||||
|
||||
self.currency1 = Currency.objects.create(
|
||||
code="BTC", name="Bitcoin", decimal_places=8, prefix=""
|
||||
)
|
||||
self.currency2 = Currency.objects.create(
|
||||
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
|
||||
)
|
||||
|
||||
# User 1's DCA strategy and entry
|
||||
self.user1_strategy = DCAStrategy.all_objects.create(
|
||||
name="User1 BTC Strategy",
|
||||
target_currency=self.currency1,
|
||||
payment_currency=self.currency2,
|
||||
owner=self.user1,
|
||||
)
|
||||
self.user1_entry = DCAEntry.objects.create(
|
||||
strategy=self.user1_strategy,
|
||||
date=date(2025, 1, 1),
|
||||
amount_paid=Decimal("100.00"),
|
||||
amount_received=Decimal("0.001"),
|
||||
)
|
||||
|
||||
# User 2's DCA strategy and entry
|
||||
self.user2_strategy = DCAStrategy.all_objects.create(
|
||||
name="User2 BTC Strategy",
|
||||
target_currency=self.currency1,
|
||||
payment_currency=self.currency2,
|
||||
owner=self.user2,
|
||||
)
|
||||
self.user2_entry = DCAEntry.objects.create(
|
||||
strategy=self.user2_strategy,
|
||||
date=date(2025, 1, 1),
|
||||
amount_paid=Decimal("200.00"),
|
||||
amount_received=Decimal("0.002"),
|
||||
)
|
||||
|
||||
def test_user_cannot_see_other_users_dca_strategies(self):
|
||||
"""GET /api/dca/strategies/ should only return user's own strategies."""
|
||||
response = self.client1.get("/api/dca/strategies/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
strategy_ids = [s["id"] for s in response.data["results"]]
|
||||
self.assertIn(self.user1_strategy.id, strategy_ids)
|
||||
self.assertNotIn(self.user2_strategy.id, strategy_ids)
|
||||
|
||||
def test_user_cannot_access_other_users_dca_strategy_detail(self):
|
||||
"""GET /api/dca/strategies/{id}/ should deny access to other user's strategy."""
|
||||
response = self.client1.get(f"/api/dca/strategies/{self.user2_strategy.id}/")
|
||||
|
||||
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
|
||||
|
||||
def test_user_cannot_access_other_users_dca_entries(self):
|
||||
"""GET /api/dca/entries/ filtered by other user's strategy should return empty."""
|
||||
response = self.client1.get(
|
||||
f"/api/dca/entries/?strategy={self.user2_strategy.id}"
|
||||
)
|
||||
|
||||
# Either OK with empty results or error
|
||||
if response.status_code == status.HTTP_200_OK:
|
||||
entry_ids = [e["id"] for e in response.data["results"]]
|
||||
self.assertNotIn(self.user2_entry.id, entry_ids)
|
||||
|
||||
def test_user_cannot_access_other_users_dca_entry_detail(self):
|
||||
"""GET /api/dca/entries/{id}/ should deny access to other user's entry."""
|
||||
response = self.client1.get(f"/api/dca/entries/{self.user2_entry.id}/")
|
||||
|
||||
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
|
||||
|
||||
def test_user_cannot_access_other_users_strategy_investment_frequency(self):
|
||||
"""investment_frequency action on other user's strategy should deny access."""
|
||||
response = self.client1.get(
|
||||
f"/api/dca/strategies/{self.user2_strategy.id}/investment_frequency/"
|
||||
)
|
||||
|
||||
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
|
||||
|
||||
def test_user_cannot_access_other_users_strategy_price_comparison(self):
|
||||
"""price_comparison action on other user's strategy should deny access."""
|
||||
response = self.client1.get(
|
||||
f"/api/dca/strategies/{self.user2_strategy.id}/price_comparison/"
|
||||
)
|
||||
|
||||
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
|
||||
|
||||
def test_user_cannot_access_other_users_strategy_current_price(self):
|
||||
"""current_price action on other user's strategy should deny access."""
|
||||
response = self.client1.get(
|
||||
f"/api/dca/strategies/{self.user2_strategy.id}/current_price/"
|
||||
)
|
||||
|
||||
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
|
||||
|
||||
def test_user_cannot_modify_other_users_dca_strategy(self):
|
||||
"""PATCH on other user's DCA strategy should deny access."""
|
||||
response = self.client1.patch(
|
||||
f"/api/dca/strategies/{self.user2_strategy.id}/",
|
||||
{"name": "Hacked Strategy"},
|
||||
)
|
||||
|
||||
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
|
||||
|
||||
def test_user_cannot_delete_other_users_dca_entry(self):
|
||||
"""DELETE on other user's DCA entry should deny access."""
|
||||
response = self.client1.delete(f"/api/dca/entries/{self.user2_entry.id}/")
|
||||
|
||||
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
|
||||
|
||||
self.assertTrue(DCAEntry.objects.filter(id=self.user2_entry.id).exists())
|
||||
|
||||
|
||||
@override_settings(
|
||||
STORAGES={
|
||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||
"staticfiles": {
|
||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||
},
|
||||
},
|
||||
WHITENOISE_AUTOREFRESH=True,
|
||||
)
|
||||
class InstallmentRecurringIsolationTests(TestCase):
|
||||
"""Tests for isolation of installment plans and recurring transactions."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
User = get_user_model()
|
||||
|
||||
self.user1 = User.objects.create_user(
|
||||
email="user1@test.com", password="testpass123"
|
||||
)
|
||||
self.client1 = APIClient()
|
||||
self.client1.force_authenticate(user=self.user1)
|
||||
|
||||
self.user2 = User.objects.create_user(
|
||||
email="user2@test.com", password="testpass123"
|
||||
)
|
||||
|
||||
self.currency = Currency.objects.create(
|
||||
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
|
||||
)
|
||||
|
||||
# User 1's account
|
||||
self.user1_account = Account.all_objects.create(
|
||||
name="User1 Account", currency=self.currency, owner=self.user1
|
||||
)
|
||||
|
||||
# User 2's account
|
||||
self.user2_account = Account.all_objects.create(
|
||||
name="User2 Account", currency=self.currency, owner=self.user2
|
||||
)
|
||||
|
||||
# User 1's installment plan
|
||||
self.user1_installment = InstallmentPlan.all_objects.create(
|
||||
account=self.user1_account,
|
||||
type=Transaction.Type.EXPENSE,
|
||||
description="User1 Installment",
|
||||
number_of_installments=12,
|
||||
start_date=date(2025, 1, 1),
|
||||
installment_amount=Decimal("100.00"),
|
||||
)
|
||||
|
||||
# User 2's installment plan
|
||||
self.user2_installment = InstallmentPlan.all_objects.create(
|
||||
account=self.user2_account,
|
||||
type=Transaction.Type.EXPENSE,
|
||||
description="User2 Installment",
|
||||
number_of_installments=6,
|
||||
start_date=date(2025, 1, 1),
|
||||
installment_amount=Decimal("200.00"),
|
||||
)
|
||||
|
||||
# User 1's recurring transaction
|
||||
self.user1_recurring = RecurringTransaction.all_objects.create(
|
||||
account=self.user1_account,
|
||||
type=Transaction.Type.EXPENSE,
|
||||
amount=Decimal("50.00"),
|
||||
description="User1 Recurring",
|
||||
start_date=date(2025, 1, 1),
|
||||
recurrence_type=RecurringTransaction.RecurrenceType.MONTH,
|
||||
recurrence_interval=1,
|
||||
)
|
||||
|
||||
# User 2's recurring transaction
|
||||
self.user2_recurring = RecurringTransaction.all_objects.create(
|
||||
account=self.user2_account,
|
||||
type=Transaction.Type.INCOME,
|
||||
amount=Decimal("1000.00"),
|
||||
description="User2 Recurring",
|
||||
start_date=date(2025, 1, 1),
|
||||
recurrence_type=RecurringTransaction.RecurrenceType.MONTH,
|
||||
recurrence_interval=1,
|
||||
)
|
||||
|
||||
def test_user_cannot_see_other_users_installment_plans(self):
|
||||
"""GET /api/installment-plans/ should only return user's own plans."""
|
||||
response = self.client1.get("/api/installment-plans/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
plan_ids = [p["id"] for p in response.data["results"]]
|
||||
self.assertIn(self.user1_installment.id, plan_ids)
|
||||
self.assertNotIn(self.user2_installment.id, plan_ids)
|
||||
|
||||
def test_user_cannot_access_other_users_installment_plan_detail(self):
|
||||
"""GET /api/installment-plans/{id}/ should deny access to other user's plan."""
|
||||
response = self.client1.get(
|
||||
f"/api/installment-plans/{self.user2_installment.id}/"
|
||||
)
|
||||
|
||||
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
|
||||
|
||||
def test_user_cannot_see_other_users_recurring_transactions(self):
|
||||
"""GET /api/recurring-transactions/ should only return user's own recurring."""
|
||||
response = self.client1.get("/api/recurring-transactions/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
recurring_ids = [r["id"] for r in response.data["results"]]
|
||||
self.assertIn(self.user1_recurring.id, recurring_ids)
|
||||
self.assertNotIn(self.user2_recurring.id, recurring_ids)
|
||||
|
||||
def test_user_cannot_access_other_users_recurring_transaction_detail(self):
|
||||
"""GET /api/recurring-transactions/{id}/ should deny access to other user's recurring."""
|
||||
response = self.client1.get(
|
||||
f"/api/recurring-transactions/{self.user2_recurring.id}/"
|
||||
)
|
||||
|
||||
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
|
||||
|
||||
def test_user_cannot_modify_other_users_installment_plan(self):
|
||||
"""PATCH on other user's installment plan should deny access."""
|
||||
response = self.client1.patch(
|
||||
f"/api/installment-plans/{self.user2_installment.id}/",
|
||||
{"description": "Hacked Installment"},
|
||||
)
|
||||
|
||||
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
|
||||
|
||||
def test_user_cannot_delete_other_users_recurring_transaction(self):
|
||||
"""DELETE on other user's recurring transaction should deny access."""
|
||||
response = self.client1.delete(
|
||||
f"/api/recurring-transactions/{self.user2_recurring.id}/"
|
||||
)
|
||||
|
||||
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
|
||||
|
||||
self.assertTrue(
|
||||
RecurringTransaction.all_objects.filter(id=self.user2_recurring.id).exists()
|
||||
)
|
||||
587
app/apps/api/tests/test_shared_access.py
Normal file
587
app/apps/api/tests/test_shared_access.py
Normal file
@@ -0,0 +1,587 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase, override_settings
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.accounts.models import Account, AccountGroup
|
||||
from apps.currencies.models import Currency
|
||||
from apps.dca.models import DCAStrategy, DCAEntry
|
||||
from apps.transactions.models import (
|
||||
Transaction,
|
||||
TransactionCategory,
|
||||
TransactionTag,
|
||||
TransactionEntity,
|
||||
)
|
||||
|
||||
|
||||
ACCESS_DENIED_CODES = [status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND]
|
||||
|
||||
|
||||
@override_settings(
|
||||
STORAGES={
|
||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||
"staticfiles": {
|
||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||
},
|
||||
},
|
||||
WHITENOISE_AUTOREFRESH=True,
|
||||
)
|
||||
class SharedAccountAccessTests(TestCase):
|
||||
"""Tests for shared account access via shared_with field."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data with shared accounts."""
|
||||
User = get_user_model()
|
||||
|
||||
# User 1 - owner
|
||||
self.user1 = User.objects.create_user(
|
||||
email="user1@test.com", password="testpass123"
|
||||
)
|
||||
self.client1 = APIClient()
|
||||
self.client1.force_authenticate(user=self.user1)
|
||||
|
||||
# User 2 - will have shared access
|
||||
self.user2 = User.objects.create_user(
|
||||
email="user2@test.com", password="testpass123"
|
||||
)
|
||||
self.client2 = APIClient()
|
||||
self.client2.force_authenticate(user=self.user2)
|
||||
|
||||
# User 3 - no shared access
|
||||
self.user3 = User.objects.create_user(
|
||||
email="user3@test.com", password="testpass123"
|
||||
)
|
||||
self.client3 = APIClient()
|
||||
self.client3.force_authenticate(user=self.user3)
|
||||
|
||||
self.currency = Currency.objects.create(
|
||||
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
|
||||
)
|
||||
|
||||
# User 1's account shared with user 2
|
||||
self.shared_account = Account.all_objects.create(
|
||||
name="Shared Account",
|
||||
currency=self.currency,
|
||||
owner=self.user1,
|
||||
visibility="private",
|
||||
)
|
||||
self.shared_account.shared_with.add(self.user2)
|
||||
|
||||
# User 1's private account (not shared)
|
||||
self.private_account = Account.all_objects.create(
|
||||
name="Private Account",
|
||||
currency=self.currency,
|
||||
owner=self.user1,
|
||||
visibility="private",
|
||||
)
|
||||
|
||||
# Transaction in shared account
|
||||
self.shared_transaction = Transaction.userless_all_objects.create(
|
||||
account=self.shared_account,
|
||||
type=Transaction.Type.INCOME,
|
||||
amount=Decimal("100.00"),
|
||||
is_paid=True,
|
||||
date=date(2025, 1, 1),
|
||||
description="Shared Transaction",
|
||||
owner=self.user1,
|
||||
)
|
||||
|
||||
# Transaction in private account
|
||||
self.private_transaction = Transaction.userless_all_objects.create(
|
||||
account=self.private_account,
|
||||
type=Transaction.Type.EXPENSE,
|
||||
amount=Decimal("50.00"),
|
||||
is_paid=True,
|
||||
date=date(2025, 1, 1),
|
||||
description="Private Transaction",
|
||||
owner=self.user1,
|
||||
)
|
||||
|
||||
def test_user_can_see_accounts_shared_with_them(self):
|
||||
"""User2 should see the account shared with them."""
|
||||
response = self.client2.get("/api/accounts/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
account_ids = [acc["id"] for acc in response.data["results"]]
|
||||
self.assertIn(self.shared_account.id, account_ids)
|
||||
|
||||
def test_user_cannot_see_accounts_not_shared_with_them(self):
|
||||
"""User2 should NOT see user1's private (non-shared) account."""
|
||||
response = self.client2.get("/api/accounts/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
account_ids = [acc["id"] for acc in response.data["results"]]
|
||||
self.assertNotIn(self.private_account.id, account_ids)
|
||||
|
||||
def test_user_can_access_shared_account_detail(self):
|
||||
"""User2 should be able to access shared account details."""
|
||||
response = self.client2.get(f"/api/accounts/{self.shared_account.id}/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["name"], "Shared Account")
|
||||
|
||||
def test_user_without_share_cannot_access_shared_account(self):
|
||||
"""User3 should NOT be able to access the shared account."""
|
||||
response = self.client3.get(f"/api/accounts/{self.shared_account.id}/")
|
||||
|
||||
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
|
||||
|
||||
def test_user_can_see_transactions_in_shared_account(self):
|
||||
"""User2 should see transactions in the shared account."""
|
||||
response = self.client2.get("/api/transactions/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
transaction_ids = [t["id"] for t in response.data["results"]]
|
||||
self.assertIn(self.shared_transaction.id, transaction_ids)
|
||||
self.assertNotIn(self.private_transaction.id, transaction_ids)
|
||||
|
||||
def test_user_can_access_transaction_in_shared_account(self):
|
||||
"""User2 should be able to access transaction details in shared account."""
|
||||
response = self.client2.get(f"/api/transactions/{self.shared_transaction.id}/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["description"], "Shared Transaction")
|
||||
|
||||
def test_user_cannot_access_transaction_in_non_shared_account(self):
|
||||
"""User2 should NOT access transactions in user1's private account."""
|
||||
response = self.client2.get(f"/api/transactions/{self.private_transaction.id}/")
|
||||
|
||||
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
|
||||
|
||||
def test_user_can_get_balance_of_shared_account(self):
|
||||
"""User2 should be able to get balance of shared account."""
|
||||
response = self.client2.get(f"/api/accounts/{self.shared_account.id}/balance/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertIn("current_balance", response.data)
|
||||
|
||||
def test_sharing_works_with_multiple_users(self):
|
||||
"""Account shared with multiple users should be accessible by all."""
|
||||
# Add user3 to shared_with
|
||||
self.shared_account.shared_with.add(self.user3)
|
||||
|
||||
# User2 still has access
|
||||
response2 = self.client2.get(f"/api/accounts/{self.shared_account.id}/")
|
||||
self.assertEqual(response2.status_code, status.HTTP_200_OK)
|
||||
|
||||
# User3 now has access
|
||||
response3 = self.client3.get(f"/api/accounts/{self.shared_account.id}/")
|
||||
self.assertEqual(response3.status_code, status.HTTP_200_OK)
|
||||
|
||||
|
||||
@override_settings(
|
||||
STORAGES={
|
||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||
"staticfiles": {
|
||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||
},
|
||||
},
|
||||
WHITENOISE_AUTOREFRESH=True,
|
||||
)
|
||||
class PublicVisibilityTests(TestCase):
|
||||
"""Tests for public visibility access."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data with public accounts."""
|
||||
User = get_user_model()
|
||||
|
||||
self.user1 = User.objects.create_user(
|
||||
email="user1@test.com", password="testpass123"
|
||||
)
|
||||
self.client1 = APIClient()
|
||||
self.client1.force_authenticate(user=self.user1)
|
||||
|
||||
self.user2 = User.objects.create_user(
|
||||
email="user2@test.com", password="testpass123"
|
||||
)
|
||||
self.client2 = APIClient()
|
||||
self.client2.force_authenticate(user=self.user2)
|
||||
|
||||
self.currency = Currency.objects.create(
|
||||
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
|
||||
)
|
||||
|
||||
# User 1's public account
|
||||
self.public_account = Account.all_objects.create(
|
||||
name="Public Account",
|
||||
currency=self.currency,
|
||||
owner=self.user1,
|
||||
visibility="public",
|
||||
)
|
||||
|
||||
# User 1's private account
|
||||
self.private_account = Account.all_objects.create(
|
||||
name="Private Account",
|
||||
currency=self.currency,
|
||||
owner=self.user1,
|
||||
visibility="private",
|
||||
)
|
||||
|
||||
# Transaction in public account
|
||||
self.public_transaction = Transaction.userless_all_objects.create(
|
||||
account=self.public_account,
|
||||
type=Transaction.Type.INCOME,
|
||||
amount=Decimal("100.00"),
|
||||
is_paid=True,
|
||||
date=date(2025, 1, 1),
|
||||
description="Public Transaction",
|
||||
owner=self.user1,
|
||||
)
|
||||
|
||||
def test_user_can_see_public_accounts(self):
|
||||
"""User2 should see user1's public account."""
|
||||
response = self.client2.get("/api/accounts/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
account_ids = [acc["id"] for acc in response.data["results"]]
|
||||
self.assertIn(self.public_account.id, account_ids)
|
||||
self.assertNotIn(self.private_account.id, account_ids)
|
||||
|
||||
def test_user_can_access_public_account_detail(self):
|
||||
"""User2 should be able to access public account details."""
|
||||
response = self.client2.get(f"/api/accounts/{self.public_account.id}/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["name"], "Public Account")
|
||||
|
||||
def test_user_can_see_transactions_in_public_accounts(self):
|
||||
"""User2 should see transactions in public accounts."""
|
||||
response = self.client2.get("/api/transactions/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
transaction_ids = [t["id"] for t in response.data["results"]]
|
||||
self.assertIn(self.public_transaction.id, transaction_ids)
|
||||
|
||||
|
||||
@override_settings(
|
||||
STORAGES={
|
||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||
"staticfiles": {
|
||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||
},
|
||||
},
|
||||
WHITENOISE_AUTOREFRESH=True,
|
||||
)
|
||||
class SharedCategoryTagEntityTests(TestCase):
|
||||
"""Tests for shared categories, tags, and entities."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data with shared categories/tags/entities."""
|
||||
User = get_user_model()
|
||||
|
||||
self.user1 = User.objects.create_user(
|
||||
email="user1@test.com", password="testpass123"
|
||||
)
|
||||
self.client1 = APIClient()
|
||||
self.client1.force_authenticate(user=self.user1)
|
||||
|
||||
self.user2 = User.objects.create_user(
|
||||
email="user2@test.com", password="testpass123"
|
||||
)
|
||||
self.client2 = APIClient()
|
||||
self.client2.force_authenticate(user=self.user2)
|
||||
|
||||
self.user3 = User.objects.create_user(
|
||||
email="user3@test.com", password="testpass123"
|
||||
)
|
||||
self.client3 = APIClient()
|
||||
self.client3.force_authenticate(user=self.user3)
|
||||
|
||||
# User 1's category shared with user 2
|
||||
self.shared_category = TransactionCategory.all_objects.create(
|
||||
name="Shared Category", owner=self.user1
|
||||
)
|
||||
self.shared_category.shared_with.add(self.user2)
|
||||
|
||||
# User 1's private category
|
||||
self.private_category = TransactionCategory.all_objects.create(
|
||||
name="Private Category", owner=self.user1
|
||||
)
|
||||
|
||||
# User 1's public category
|
||||
self.public_category = TransactionCategory.all_objects.create(
|
||||
name="Public Category", owner=self.user1, visibility="public"
|
||||
)
|
||||
|
||||
# User 1's tag shared with user 2
|
||||
self.shared_tag = TransactionTag.all_objects.create(
|
||||
name="Shared Tag", owner=self.user1
|
||||
)
|
||||
self.shared_tag.shared_with.add(self.user2)
|
||||
|
||||
# User 1's entity shared with user 2
|
||||
self.shared_entity = TransactionEntity.all_objects.create(
|
||||
name="Shared Entity", owner=self.user1
|
||||
)
|
||||
self.shared_entity.shared_with.add(self.user2)
|
||||
|
||||
def test_user_can_see_shared_categories(self):
|
||||
"""User2 should see categories shared with them."""
|
||||
response = self.client2.get("/api/categories/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
category_ids = [c["id"] for c in response.data["results"]]
|
||||
self.assertIn(self.shared_category.id, category_ids)
|
||||
self.assertNotIn(self.private_category.id, category_ids)
|
||||
|
||||
def test_user_can_access_shared_category_detail(self):
|
||||
"""User2 should be able to access shared category details."""
|
||||
response = self.client2.get(f"/api/categories/{self.shared_category.id}/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["name"], "Shared Category")
|
||||
|
||||
def test_user_can_see_public_categories(self):
|
||||
"""User3 should see public categories."""
|
||||
response = self.client3.get("/api/categories/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
category_ids = [c["id"] for c in response.data["results"]]
|
||||
self.assertIn(self.public_category.id, category_ids)
|
||||
|
||||
def test_user_without_share_cannot_see_shared_category(self):
|
||||
"""User3 should NOT see category shared only with user2."""
|
||||
response = self.client3.get("/api/categories/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
category_ids = [c["id"] for c in response.data["results"]]
|
||||
self.assertNotIn(self.shared_category.id, category_ids)
|
||||
|
||||
def test_user_can_see_shared_tags(self):
|
||||
"""User2 should see tags shared with them."""
|
||||
response = self.client2.get("/api/tags/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
tag_ids = [t["id"] for t in response.data["results"]]
|
||||
self.assertIn(self.shared_tag.id, tag_ids)
|
||||
|
||||
def test_user_can_access_shared_tag_detail(self):
|
||||
"""User2 should be able to access shared tag details."""
|
||||
response = self.client2.get(f"/api/tags/{self.shared_tag.id}/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["name"], "Shared Tag")
|
||||
|
||||
def test_user_can_see_shared_entities(self):
|
||||
"""User2 should see entities shared with them."""
|
||||
response = self.client2.get("/api/entities/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
entity_ids = [e["id"] for e in response.data["results"]]
|
||||
self.assertIn(self.shared_entity.id, entity_ids)
|
||||
|
||||
def test_user_can_access_shared_entity_detail(self):
|
||||
"""User2 should be able to access shared entity details."""
|
||||
response = self.client2.get(f"/api/entities/{self.shared_entity.id}/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["name"], "Shared Entity")
|
||||
|
||||
|
||||
@override_settings(
|
||||
STORAGES={
|
||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||
"staticfiles": {
|
||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||
},
|
||||
},
|
||||
WHITENOISE_AUTOREFRESH=True,
|
||||
)
|
||||
class SharedDCAAccessTests(TestCase):
|
||||
"""Tests for shared DCA strategy access."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data with shared DCA strategies."""
|
||||
User = get_user_model()
|
||||
|
||||
self.user1 = User.objects.create_user(
|
||||
email="user1@test.com", password="testpass123"
|
||||
)
|
||||
self.client1 = APIClient()
|
||||
self.client1.force_authenticate(user=self.user1)
|
||||
|
||||
self.user2 = User.objects.create_user(
|
||||
email="user2@test.com", password="testpass123"
|
||||
)
|
||||
self.client2 = APIClient()
|
||||
self.client2.force_authenticate(user=self.user2)
|
||||
|
||||
self.user3 = User.objects.create_user(
|
||||
email="user3@test.com", password="testpass123"
|
||||
)
|
||||
self.client3 = APIClient()
|
||||
self.client3.force_authenticate(user=self.user3)
|
||||
|
||||
self.currency1 = Currency.objects.create(
|
||||
code="BTC", name="Bitcoin", decimal_places=8, prefix=""
|
||||
)
|
||||
self.currency2 = Currency.objects.create(
|
||||
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
|
||||
)
|
||||
|
||||
# User 1's DCA strategy shared with user 2
|
||||
self.shared_strategy = DCAStrategy.all_objects.create(
|
||||
name="Shared BTC Strategy",
|
||||
target_currency=self.currency1,
|
||||
payment_currency=self.currency2,
|
||||
owner=self.user1,
|
||||
)
|
||||
self.shared_strategy.shared_with.add(self.user2)
|
||||
|
||||
# Entry in shared strategy
|
||||
self.shared_entry = DCAEntry.objects.create(
|
||||
strategy=self.shared_strategy,
|
||||
date=date(2025, 1, 1),
|
||||
amount_paid=Decimal("100.00"),
|
||||
amount_received=Decimal("0.001"),
|
||||
)
|
||||
|
||||
# User 1's private strategy
|
||||
self.private_strategy = DCAStrategy.all_objects.create(
|
||||
name="Private BTC Strategy",
|
||||
target_currency=self.currency1,
|
||||
payment_currency=self.currency2,
|
||||
owner=self.user1,
|
||||
)
|
||||
|
||||
def test_user_can_see_shared_dca_strategies(self):
|
||||
"""User2 should see DCA strategies shared with them."""
|
||||
response = self.client2.get("/api/dca/strategies/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
strategy_ids = [s["id"] for s in response.data["results"]]
|
||||
self.assertIn(self.shared_strategy.id, strategy_ids)
|
||||
self.assertNotIn(self.private_strategy.id, strategy_ids)
|
||||
|
||||
def test_user_can_access_shared_dca_strategy_detail(self):
|
||||
"""User2 should be able to access shared strategy details."""
|
||||
response = self.client2.get(f"/api/dca/strategies/{self.shared_strategy.id}/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["name"], "Shared BTC Strategy")
|
||||
|
||||
def test_user_without_share_cannot_see_shared_strategy(self):
|
||||
"""User3 should NOT see strategy shared only with user2."""
|
||||
response = self.client3.get("/api/dca/strategies/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
strategy_ids = [s["id"] for s in response.data["results"]]
|
||||
self.assertNotIn(self.shared_strategy.id, strategy_ids)
|
||||
|
||||
def test_user_can_access_shared_strategy_actions(self):
|
||||
"""User2 should be able to access actions on shared strategy."""
|
||||
# investment_frequency
|
||||
response1 = self.client2.get(
|
||||
f"/api/dca/strategies/{self.shared_strategy.id}/investment_frequency/"
|
||||
)
|
||||
self.assertEqual(response1.status_code, status.HTTP_200_OK)
|
||||
|
||||
# price_comparison
|
||||
response2 = self.client2.get(
|
||||
f"/api/dca/strategies/{self.shared_strategy.id}/price_comparison/"
|
||||
)
|
||||
self.assertEqual(response2.status_code, status.HTTP_200_OK)
|
||||
|
||||
# current_price
|
||||
response3 = self.client2.get(
|
||||
f"/api/dca/strategies/{self.shared_strategy.id}/current_price/"
|
||||
)
|
||||
self.assertEqual(response3.status_code, status.HTTP_200_OK)
|
||||
|
||||
|
||||
@override_settings(
|
||||
STORAGES={
|
||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||
"staticfiles": {
|
||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||
},
|
||||
},
|
||||
WHITENOISE_AUTOREFRESH=True,
|
||||
)
|
||||
class SharedAccountGroupTests(TestCase):
|
||||
"""Tests for shared account group access."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data with shared account groups."""
|
||||
User = get_user_model()
|
||||
|
||||
self.user1 = User.objects.create_user(
|
||||
email="user1@test.com", password="testpass123"
|
||||
)
|
||||
self.client1 = APIClient()
|
||||
self.client1.force_authenticate(user=self.user1)
|
||||
|
||||
self.user2 = User.objects.create_user(
|
||||
email="user2@test.com", password="testpass123"
|
||||
)
|
||||
self.client2 = APIClient()
|
||||
self.client2.force_authenticate(user=self.user2)
|
||||
|
||||
self.user3 = User.objects.create_user(
|
||||
email="user3@test.com", password="testpass123"
|
||||
)
|
||||
self.client3 = APIClient()
|
||||
self.client3.force_authenticate(user=self.user3)
|
||||
|
||||
# User 1's account group shared with user 2
|
||||
self.shared_group = AccountGroup.all_objects.create(
|
||||
name="Shared Group", owner=self.user1
|
||||
)
|
||||
self.shared_group.shared_with.add(self.user2)
|
||||
|
||||
# User 1's private account group
|
||||
self.private_group = AccountGroup.all_objects.create(
|
||||
name="Private Group", owner=self.user1
|
||||
)
|
||||
|
||||
# User 1's public account group
|
||||
self.public_group = AccountGroup.all_objects.create(
|
||||
name="Public Group", owner=self.user1, visibility="public"
|
||||
)
|
||||
|
||||
def test_user_can_see_shared_account_groups(self):
|
||||
"""User2 should see account groups shared with them."""
|
||||
response = self.client2.get("/api/account-groups/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
group_ids = [g["id"] for g in response.data["results"]]
|
||||
self.assertIn(self.shared_group.id, group_ids)
|
||||
self.assertNotIn(self.private_group.id, group_ids)
|
||||
|
||||
def test_user_can_access_shared_account_group_detail(self):
|
||||
"""User2 should be able to access shared account group details."""
|
||||
response = self.client2.get(f"/api/account-groups/{self.shared_group.id}/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["name"], "Shared Group")
|
||||
|
||||
def test_user_can_see_public_account_groups(self):
|
||||
"""User3 should see public account groups."""
|
||||
response = self.client3.get("/api/account-groups/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
group_ids = [g["id"] for g in response.data["results"]]
|
||||
self.assertIn(self.public_group.id, group_ids)
|
||||
|
||||
def test_user_without_share_cannot_access_shared_group(self):
|
||||
"""User3 should NOT be able to access shared account group."""
|
||||
response = self.client3.get(f"/api/account-groups/{self.shared_group.id}/")
|
||||
|
||||
self.assertIn(response.status_code, ACCESS_DENIED_CODES)
|
||||
@@ -6,7 +6,11 @@ from rest_framework.response import Response
|
||||
|
||||
from apps.accounts.models import AccountGroup, Account
|
||||
from apps.accounts.services import get_account_balance
|
||||
from apps.api.serializers import AccountGroupSerializer, AccountSerializer, AccountBalanceSerializer
|
||||
from apps.api.serializers import (
|
||||
AccountGroupSerializer,
|
||||
AccountSerializer,
|
||||
AccountBalanceSerializer,
|
||||
)
|
||||
|
||||
|
||||
class AccountGroupViewSet(viewsets.ModelViewSet):
|
||||
@@ -14,9 +18,16 @@ class AccountGroupViewSet(viewsets.ModelViewSet):
|
||||
|
||||
queryset = AccountGroup.objects.all()
|
||||
serializer_class = AccountGroupSerializer
|
||||
filterset_fields = {
|
||||
"name": ["exact", "icontains"],
|
||||
"owner": ["exact"],
|
||||
}
|
||||
search_fields = ["name"]
|
||||
ordering_fields = "__all__"
|
||||
ordering = ["id"]
|
||||
|
||||
def get_queryset(self):
|
||||
return AccountGroup.objects.all().order_by("id")
|
||||
return AccountGroup.objects.all()
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
@@ -31,27 +42,38 @@ class AccountViewSet(viewsets.ModelViewSet):
|
||||
|
||||
queryset = Account.objects.all()
|
||||
serializer_class = AccountSerializer
|
||||
filterset_fields = {
|
||||
"name": ["exact", "icontains"],
|
||||
"group": ["exact", "isnull"],
|
||||
"currency": ["exact"],
|
||||
"exchange_currency": ["exact", "isnull"],
|
||||
"is_asset": ["exact"],
|
||||
"is_archived": ["exact"],
|
||||
"owner": ["exact"],
|
||||
}
|
||||
search_fields = ["name"]
|
||||
ordering_fields = "__all__"
|
||||
ordering = ["id"]
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Account.objects.all()
|
||||
.order_by("id")
|
||||
.select_related("group", "currency", "exchange_currency")
|
||||
return Account.objects.all().select_related(
|
||||
"group", "currency", "exchange_currency"
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["get"], permission_classes=[IsAuthenticated])
|
||||
def balance(self, request, pk=None):
|
||||
"""Get current and projected balance for an account."""
|
||||
account = self.get_object()
|
||||
|
||||
|
||||
current_balance = get_account_balance(account, paid_only=True)
|
||||
projected_balance = get_account_balance(account, paid_only=False)
|
||||
|
||||
serializer = AccountBalanceSerializer({
|
||||
"current_balance": current_balance,
|
||||
"projected_balance": projected_balance,
|
||||
"currency": account.currency,
|
||||
})
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
serializer = AccountBalanceSerializer(
|
||||
{
|
||||
"current_balance": current_balance,
|
||||
"projected_balance": projected_balance,
|
||||
"currency": account.currency,
|
||||
}
|
||||
)
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
@@ -9,8 +9,28 @@ from apps.currencies.models import ExchangeRate
|
||||
class CurrencyViewSet(viewsets.ModelViewSet):
|
||||
queryset = Currency.objects.all()
|
||||
serializer_class = CurrencySerializer
|
||||
filterset_fields = {
|
||||
'name': ['exact', 'icontains'],
|
||||
'code': ['exact', 'icontains'],
|
||||
'decimal_places': ['exact', 'gte', 'lte', 'gt', 'lt'],
|
||||
'prefix': ['exact', 'icontains'],
|
||||
'suffix': ['exact', 'icontains'],
|
||||
'exchange_currency': ['exact'],
|
||||
'is_archived': ['exact'],
|
||||
}
|
||||
search_fields = '__all__'
|
||||
ordering_fields = '__all__'
|
||||
|
||||
|
||||
class ExchangeRateViewSet(viewsets.ModelViewSet):
|
||||
queryset = ExchangeRate.objects.all()
|
||||
serializer_class = ExchangeRateSerializer
|
||||
filterset_fields = {
|
||||
'from_currency': ['exact'],
|
||||
'to_currency': ['exact'],
|
||||
'rate': ['exact', 'gte', 'lte', 'gt', 'lt'],
|
||||
'date': ['exact', 'gte', 'lte', 'gt', 'lt'],
|
||||
'automatic': ['exact'],
|
||||
}
|
||||
search_fields = '__all__'
|
||||
ordering_fields = '__all__'
|
||||
|
||||
@@ -8,6 +8,19 @@ from apps.api.serializers import DCAStrategySerializer, DCAEntrySerializer
|
||||
class DCAStrategyViewSet(viewsets.ModelViewSet):
|
||||
queryset = DCAStrategy.objects.all()
|
||||
serializer_class = DCAStrategySerializer
|
||||
filterset_fields = {
|
||||
"name": ["exact", "icontains"],
|
||||
"target_currency": ["exact"],
|
||||
"payment_currency": ["exact"],
|
||||
"notes": ["exact", "icontains"],
|
||||
"created_at": ["exact", "gte", "lte", "gt", "lt"],
|
||||
"updated_at": ["exact", "gte", "lte", "gt", "lt"],
|
||||
}
|
||||
search_fields = ["name", "notes"]
|
||||
ordering_fields = "__all__"
|
||||
|
||||
def get_queryset(self):
|
||||
return DCAStrategy.objects.all()
|
||||
|
||||
def get_queryset(self):
|
||||
return DCAStrategy.objects.all().order_by("id")
|
||||
@@ -35,10 +48,22 @@ class DCAStrategyViewSet(viewsets.ModelViewSet):
|
||||
class DCAEntryViewSet(viewsets.ModelViewSet):
|
||||
queryset = DCAEntry.objects.all()
|
||||
serializer_class = DCAEntrySerializer
|
||||
filterset_fields = {
|
||||
"strategy": ["exact"],
|
||||
"date": ["exact", "gte", "lte", "gt", "lt"],
|
||||
"amount_paid": ["exact", "gte", "lte", "gt", "lt"],
|
||||
"amount_received": ["exact", "gte", "lte", "gt", "lt"],
|
||||
"expense_transaction": ["exact", "isnull"],
|
||||
"income_transaction": ["exact", "isnull"],
|
||||
"notes": ["exact", "icontains"],
|
||||
"created_at": ["exact", "gte", "lte", "gt", "lt"],
|
||||
"updated_at": ["exact", "gte", "lte", "gt", "lt"],
|
||||
}
|
||||
search_fields = ["notes"]
|
||||
ordering_fields = "__all__"
|
||||
ordering = ["-date"]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = DCAEntry.objects.all()
|
||||
strategy_id = self.request.query_params.get("strategy", None)
|
||||
if strategy_id is not None:
|
||||
queryset = queryset.filter(strategy_id=strategy_id)
|
||||
return queryset
|
||||
# Filter entries by strategies the user has access to
|
||||
accessible_strategies = DCAStrategy.objects.all()
|
||||
return DCAEntry.objects.filter(strategy__in=accessible_strategies)
|
||||
|
||||
@@ -28,6 +28,14 @@ class ImportProfileViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
queryset = ImportProfile.objects.all()
|
||||
serializer_class = ImportProfileSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
filterset_fields = {
|
||||
'name': ['exact', 'icontains'],
|
||||
'yaml_config': ['exact', 'icontains'],
|
||||
'version': ['exact'],
|
||||
}
|
||||
search_fields = ['name', 'yaml_config']
|
||||
ordering_fields = '__all__'
|
||||
ordering = ['name']
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
@@ -55,6 +63,22 @@ class ImportRunViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
queryset = ImportRun.objects.all().order_by("-id")
|
||||
serializer_class = ImportRunSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
filterset_fields = {
|
||||
'status': ['exact'],
|
||||
'profile': ['exact'],
|
||||
'file_name': ['exact', 'icontains'],
|
||||
'logs': ['exact', 'icontains'],
|
||||
'processed_rows': ['exact', 'gte', 'lte', 'gt', 'lt'],
|
||||
'total_rows': ['exact', 'gte', 'lte', 'gt', 'lt'],
|
||||
'successful_rows': ['exact', 'gte', 'lte', 'gt', 'lt'],
|
||||
'skipped_rows': ['exact', 'gte', 'lte', 'gt', 'lt'],
|
||||
'failed_rows': ['exact', 'gte', 'lte', 'gt', 'lt'],
|
||||
'started_at': ['exact', 'gte', 'lte', 'gt', 'lt', 'isnull'],
|
||||
'finished_at': ['exact', 'gte', 'lte', 'gt', 'lt', 'isnull'],
|
||||
}
|
||||
search_fields = ['file_name', 'logs']
|
||||
ordering_fields = '__all__'
|
||||
ordering = ['-id']
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
|
||||
@@ -24,6 +24,34 @@ from apps.rules.signals import transaction_updated, transaction_created
|
||||
class TransactionViewSet(viewsets.ModelViewSet):
|
||||
queryset = Transaction.objects.all()
|
||||
serializer_class = TransactionSerializer
|
||||
filterset_fields = {
|
||||
"account": ["exact"],
|
||||
"type": ["exact"],
|
||||
"is_paid": ["exact"],
|
||||
"date": ["exact", "gte", "lte", "gt", "lt"],
|
||||
"reference_date": ["exact", "gte", "lte", "gt", "lt"],
|
||||
"mute": ["exact"],
|
||||
"amount": ["exact", "gte", "lte", "gt", "lt"],
|
||||
"description": ["exact", "icontains"],
|
||||
"notes": ["exact", "icontains"],
|
||||
"category": ["exact", "isnull"],
|
||||
"installment_plan": ["exact", "isnull"],
|
||||
"installment_id": ["exact", "gte", "lte"],
|
||||
"recurring_transaction": ["exact", "isnull"],
|
||||
"internal_note": ["exact", "icontains"],
|
||||
"internal_id": ["exact"],
|
||||
"deleted": ["exact"],
|
||||
"created_at": ["exact", "gte", "lte", "gt", "lt"],
|
||||
"updated_at": ["exact", "gte", "lte", "gt", "lt"],
|
||||
"deleted_at": ["exact", "gte", "lte", "gt", "lt", "isnull"],
|
||||
"owner": ["exact"],
|
||||
}
|
||||
search_fields = ["description", "notes", "internal_note"]
|
||||
ordering_fields = "__all__"
|
||||
ordering = ["-id"]
|
||||
|
||||
def get_queryset(self):
|
||||
return Transaction.objects.all()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
instance = serializer.save()
|
||||
@@ -38,45 +66,109 @@ class TransactionViewSet(viewsets.ModelViewSet):
|
||||
kwargs["partial"] = True
|
||||
return self.update(request, *args, **kwargs)
|
||||
|
||||
def get_queryset(self):
|
||||
return Transaction.objects.all().order_by("-id")
|
||||
|
||||
|
||||
class TransactionCategoryViewSet(viewsets.ModelViewSet):
|
||||
queryset = TransactionCategory.objects.all()
|
||||
serializer_class = TransactionCategorySerializer
|
||||
filterset_fields = {
|
||||
"name": ["exact", "icontains"],
|
||||
"mute": ["exact"],
|
||||
"active": ["exact"],
|
||||
"owner": ["exact"],
|
||||
}
|
||||
search_fields = ["name"]
|
||||
ordering_fields = "__all__"
|
||||
ordering = ["id"]
|
||||
|
||||
def get_queryset(self):
|
||||
return TransactionCategory.objects.all().order_by("id")
|
||||
return TransactionCategory.objects.all()
|
||||
|
||||
|
||||
class TransactionTagViewSet(viewsets.ModelViewSet):
|
||||
queryset = TransactionTag.objects.all()
|
||||
serializer_class = TransactionTagSerializer
|
||||
filterset_fields = {
|
||||
"name": ["exact", "icontains"],
|
||||
"active": ["exact"],
|
||||
"owner": ["exact"],
|
||||
}
|
||||
search_fields = ["name"]
|
||||
ordering_fields = "__all__"
|
||||
ordering = ["id"]
|
||||
|
||||
def get_queryset(self):
|
||||
return TransactionTag.objects.all().order_by("id")
|
||||
return TransactionTag.objects.all()
|
||||
|
||||
|
||||
class TransactionEntityViewSet(viewsets.ModelViewSet):
|
||||
queryset = TransactionEntity.objects.all()
|
||||
serializer_class = TransactionEntitySerializer
|
||||
filterset_fields = {
|
||||
"name": ["exact", "icontains"],
|
||||
"active": ["exact"],
|
||||
"owner": ["exact"],
|
||||
}
|
||||
search_fields = ["name"]
|
||||
ordering_fields = "__all__"
|
||||
ordering = ["id"]
|
||||
|
||||
def get_queryset(self):
|
||||
return TransactionEntity.objects.all().order_by("id")
|
||||
return TransactionEntity.objects.all()
|
||||
|
||||
|
||||
class InstallmentPlanViewSet(viewsets.ModelViewSet):
|
||||
queryset = InstallmentPlan.objects.all()
|
||||
serializer_class = InstallmentPlanSerializer
|
||||
filterset_fields = {
|
||||
"account": ["exact"],
|
||||
"type": ["exact"],
|
||||
"description": ["exact", "icontains"],
|
||||
"number_of_installments": ["exact", "gte", "lte", "gt", "lt"],
|
||||
"installment_start": ["exact", "gte", "lte", "gt", "lt"],
|
||||
"installment_total_number": ["exact", "gte", "lte", "gt", "lt"],
|
||||
"start_date": ["exact", "gte", "lte", "gt", "lt"],
|
||||
"reference_date": ["exact", "gte", "lte", "gt", "lt", "isnull"],
|
||||
"end_date": ["exact", "gte", "lte", "gt", "lt", "isnull"],
|
||||
"recurrence": ["exact"],
|
||||
"installment_amount": ["exact", "gte", "lte", "gt", "lt"],
|
||||
"category": ["exact", "isnull"],
|
||||
"notes": ["exact", "icontains"],
|
||||
"add_description_to_transaction": ["exact"],
|
||||
"add_notes_to_transaction": ["exact"],
|
||||
}
|
||||
search_fields = ["description", "notes"]
|
||||
ordering_fields = "__all__"
|
||||
ordering = ["-id"]
|
||||
|
||||
def get_queryset(self):
|
||||
return InstallmentPlan.objects.all().order_by("-id")
|
||||
return InstallmentPlan.objects.all()
|
||||
|
||||
|
||||
class RecurringTransactionViewSet(viewsets.ModelViewSet):
|
||||
queryset = RecurringTransaction.objects.all()
|
||||
serializer_class = RecurringTransactionSerializer
|
||||
filterset_fields = {
|
||||
"is_paused": ["exact"],
|
||||
"account": ["exact"],
|
||||
"type": ["exact"],
|
||||
"amount": ["exact", "gte", "lte", "gt", "lt"],
|
||||
"description": ["exact", "icontains"],
|
||||
"category": ["exact", "isnull"],
|
||||
"notes": ["exact", "icontains"],
|
||||
"reference_date": ["exact", "gte", "lte", "gt", "lt", "isnull"],
|
||||
"start_date": ["exact", "gte", "lte", "gt", "lt"],
|
||||
"end_date": ["exact", "gte", "lte", "gt", "lt", "isnull"],
|
||||
"recurrence_type": ["exact"],
|
||||
"recurrence_interval": ["exact", "gte", "lte", "gt", "lt"],
|
||||
"keep_at_most": ["exact", "gte", "lte", "gt", "lt"],
|
||||
"last_generated_date": ["exact", "gte", "lte", "gt", "lt", "isnull"],
|
||||
"last_generated_reference_date": ["exact", "gte", "lte", "gt", "lt", "isnull"],
|
||||
"add_description_to_transaction": ["exact"],
|
||||
"add_notes_to_transaction": ["exact"],
|
||||
}
|
||||
search_fields = ["description", "notes"]
|
||||
ordering_fields = "__all__"
|
||||
ordering = ["-id"]
|
||||
|
||||
def get_queryset(self):
|
||||
return RecurringTransaction.objects.all().order_by("-id")
|
||||
return RecurringTransaction.objects.all()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db.models import QuerySet
|
||||
from django.utils import timezone
|
||||
@@ -258,7 +257,10 @@ class ExchangeRateFetcher:
|
||||
processed_pairs.add((from_currency.id, to_currency.id))
|
||||
|
||||
service.last_fetch = timezone.now()
|
||||
service.failure_count = 0
|
||||
service.save()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching rates for {service.name}: {e}")
|
||||
service.failure_count += 1
|
||||
service.save()
|
||||
|
||||
18
app/apps/currencies/migrations/0023_add_failure_count.py
Normal file
18
app/apps/currencies/migrations/0023_add_failure_count.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.10 on 2026-01-10 06:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('currencies', '0022_currency_is_archived'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='exchangerateservice',
|
||||
name='failure_count',
|
||||
field=models.PositiveIntegerField(default=0),
|
||||
),
|
||||
]
|
||||
@@ -136,6 +136,8 @@ class ExchangeRateService(models.Model):
|
||||
null=True, blank=True, verbose_name=_("Last Successful Fetch")
|
||||
)
|
||||
|
||||
failure_count = models.PositiveIntegerField(default=0)
|
||||
|
||||
target_currencies = models.ManyToManyField(
|
||||
Currency,
|
||||
verbose_name=_("Target Currencies"),
|
||||
@@ -237,7 +239,7 @@ class ExchangeRateService(models.Model):
|
||||
hours = self._parse_hour_ranges(self.fetch_interval)
|
||||
# Store in normalized format (optional)
|
||||
self.fetch_interval = ",".join(str(h) for h in sorted(hours))
|
||||
except ValueError as e:
|
||||
except ValueError:
|
||||
raise ValidationError(
|
||||
{
|
||||
"fetch_interval": _(
|
||||
@@ -248,7 +250,7 @@ class ExchangeRateService(models.Model):
|
||||
)
|
||||
except ValidationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
raise ValidationError(
|
||||
{
|
||||
"fetch_interval": _(
|
||||
|
||||
1
app/apps/currencies/tests/__init__.py
Normal file
1
app/apps/currencies/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Tests package for currencies app
|
||||
109
app/apps/currencies/tests/test_automatic_exchange_rates.py
Normal file
109
app/apps/currencies/tests/test_automatic_exchange_rates.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from decimal import Decimal
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.currencies.models import Currency, ExchangeRateService
|
||||
from apps.currencies.exchange_rates.fetcher import ExchangeRateFetcher
|
||||
|
||||
|
||||
class ExchangeRateServiceFailureTrackingTests(TestCase):
|
||||
"""Tests for the failure count tracking functionality."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.usd = Currency.objects.create(
|
||||
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
|
||||
)
|
||||
self.eur = Currency.objects.create(
|
||||
code="EUR", name="Euro", decimal_places=2, prefix="€ "
|
||||
)
|
||||
self.eur.exchange_currency = self.usd
|
||||
self.eur.save()
|
||||
|
||||
self.service = ExchangeRateService.objects.create(
|
||||
name="Test Service",
|
||||
service_type=ExchangeRateService.ServiceType.FRANKFURTER,
|
||||
is_active=True,
|
||||
)
|
||||
self.service.target_currencies.add(self.eur)
|
||||
|
||||
def test_failure_count_increments_on_provider_error(self):
|
||||
"""Test that failure_count increments when provider raises an exception."""
|
||||
self.assertEqual(self.service.failure_count, 0)
|
||||
|
||||
with patch.object(
|
||||
self.service, "get_provider", side_effect=Exception("API Error")
|
||||
):
|
||||
ExchangeRateFetcher._fetch_service_rates(self.service)
|
||||
|
||||
self.service.refresh_from_db()
|
||||
self.assertEqual(self.service.failure_count, 1)
|
||||
|
||||
def test_failure_count_resets_on_success(self):
|
||||
"""Test that failure_count resets to 0 on successful fetch."""
|
||||
# Set initial failure count
|
||||
self.service.failure_count = 5
|
||||
self.service.save()
|
||||
|
||||
# Mock a successful provider
|
||||
mock_provider = MagicMock()
|
||||
mock_provider.requires_api_key.return_value = False
|
||||
mock_provider.get_rates.return_value = [(self.usd, self.eur, Decimal("0.85"))]
|
||||
mock_provider.rates_inverted = False
|
||||
|
||||
with patch.object(self.service, "get_provider", return_value=mock_provider):
|
||||
ExchangeRateFetcher._fetch_service_rates(self.service)
|
||||
|
||||
self.service.refresh_from_db()
|
||||
self.assertEqual(self.service.failure_count, 0)
|
||||
|
||||
def test_failure_count_accumulates_across_fetches(self):
|
||||
"""Test that failure_count accumulates with consecutive failures."""
|
||||
self.assertEqual(self.service.failure_count, 0)
|
||||
|
||||
with patch.object(
|
||||
self.service, "get_provider", side_effect=Exception("API Error")
|
||||
):
|
||||
ExchangeRateFetcher._fetch_service_rates(self.service)
|
||||
self.service.refresh_from_db()
|
||||
self.assertEqual(self.service.failure_count, 1)
|
||||
|
||||
ExchangeRateFetcher._fetch_service_rates(self.service)
|
||||
self.service.refresh_from_db()
|
||||
self.assertEqual(self.service.failure_count, 2)
|
||||
|
||||
ExchangeRateFetcher._fetch_service_rates(self.service)
|
||||
self.service.refresh_from_db()
|
||||
self.assertEqual(self.service.failure_count, 3)
|
||||
|
||||
def test_last_fetch_not_updated_on_failure(self):
|
||||
"""Test that last_fetch is NOT updated when a failure occurs."""
|
||||
original_last_fetch = self.service.last_fetch
|
||||
self.assertIsNone(original_last_fetch)
|
||||
|
||||
with patch.object(
|
||||
self.service, "get_provider", side_effect=Exception("API Error")
|
||||
):
|
||||
ExchangeRateFetcher._fetch_service_rates(self.service)
|
||||
|
||||
self.service.refresh_from_db()
|
||||
self.assertIsNone(self.service.last_fetch)
|
||||
self.assertEqual(self.service.failure_count, 1)
|
||||
|
||||
def test_last_fetch_updated_on_success(self):
|
||||
"""Test that last_fetch IS updated when fetch succeeds."""
|
||||
self.assertIsNone(self.service.last_fetch)
|
||||
|
||||
mock_provider = MagicMock()
|
||||
mock_provider.requires_api_key.return_value = False
|
||||
mock_provider.get_rates.return_value = [(self.usd, self.eur, Decimal("0.85"))]
|
||||
mock_provider.rates_inverted = False
|
||||
|
||||
with patch.object(self.service, "get_provider", return_value=mock_provider):
|
||||
ExchangeRateFetcher._fetch_service_rates(self.service)
|
||||
|
||||
self.service.refresh_from_db()
|
||||
self.assertIsNotNone(self.service.last_fetch)
|
||||
self.assertEqual(self.service.failure_count, 0)
|
||||
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-01-10 05:53+0000\n"
|
||||
"POT-Creation-Date: 2026-01-10 20:50+0000\n"
|
||||
"PO-Revision-Date: 2025-11-01 01:17+0000\n"
|
||||
"Last-Translator: mlystopad <mlystopadt@gmail.com>\n"
|
||||
"Language-Team: German <https://translations.herculino.com/projects/wygiwyh/"
|
||||
@@ -608,11 +608,11 @@ msgstr "Intervall"
|
||||
msgid "Last Successful Fetch"
|
||||
msgstr "Letzter erfolgreicher Abruf"
|
||||
|
||||
#: apps/currencies/models.py:141
|
||||
#: apps/currencies/models.py:143
|
||||
msgid "Target Currencies"
|
||||
msgstr "Zielwährungen"
|
||||
|
||||
#: apps/currencies/models.py:143
|
||||
#: apps/currencies/models.py:145
|
||||
msgid ""
|
||||
"Select currencies to fetch exchange rates for. Rates will be fetched for "
|
||||
"each currency against their set exchange currency."
|
||||
@@ -620,11 +620,11 @@ msgstr ""
|
||||
"Währung auswählen, dessen Umrechnungskurs abgerufen werden sollen. Für jede "
|
||||
"Währung wird der Kurs der entsprechenden Umrechnungs-Währung abgerufen."
|
||||
|
||||
#: apps/currencies/models.py:151
|
||||
#: apps/currencies/models.py:153
|
||||
msgid "Target Accounts"
|
||||
msgstr "Zielkonten"
|
||||
|
||||
#: apps/currencies/models.py:153
|
||||
#: apps/currencies/models.py:155
|
||||
msgid ""
|
||||
"Select accounts to fetch exchange rates for. Rates will be fetched for each "
|
||||
"account's currency against their set exchange currency."
|
||||
@@ -632,33 +632,33 @@ msgstr ""
|
||||
"Konten auswählen, für die Umrechungskurse abgerufen werden solen. Für jedes "
|
||||
"Konto wird der Kurs der entsprechenden Umrechnungs-Währung abgerufen."
|
||||
|
||||
#: apps/currencies/models.py:160
|
||||
#: apps/currencies/models.py:162
|
||||
#, fuzzy
|
||||
#| msgid "Edit exchange rate"
|
||||
msgid "Single exchange rate"
|
||||
msgstr "Umrechnungskurs bearbeiten"
|
||||
|
||||
#: apps/currencies/models.py:163
|
||||
#: apps/currencies/models.py:165
|
||||
msgid "Create one exchange rate and keep updating it. Avoids database clutter."
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:168
|
||||
#: apps/currencies/models.py:170
|
||||
msgid "Exchange Rate Service"
|
||||
msgstr "Umrechnungskurs-Dienst"
|
||||
|
||||
#: apps/currencies/models.py:169
|
||||
#: apps/currencies/models.py:171
|
||||
msgid "Exchange Rate Services"
|
||||
msgstr "Umrechnungskurs-Dienste"
|
||||
|
||||
#: apps/currencies/models.py:221
|
||||
#: apps/currencies/models.py:223
|
||||
msgid "'Every X hours' interval type requires a positive integer."
|
||||
msgstr "\"Jede X Stunden\"-Intervalltyp benötigt eine positive Ganzzahl."
|
||||
|
||||
#: apps/currencies/models.py:230
|
||||
#: apps/currencies/models.py:232
|
||||
msgid "'Every X hours' interval must be between 1 and 24."
|
||||
msgstr "\"Jede X Stunden\"-Intervall muss zwischen 1 und 24 liegen."
|
||||
|
||||
#: apps/currencies/models.py:244
|
||||
#: apps/currencies/models.py:246
|
||||
msgid ""
|
||||
"Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., "
|
||||
"'1-5,8,10-12')."
|
||||
@@ -666,7 +666,7 @@ msgstr ""
|
||||
"Ungültiges Stundenformat. Nutze kommagetrennte Stunden (0-23) und/oder "
|
||||
"Zeiträume (z.B. \"1-5,8,10-12\")."
|
||||
|
||||
#: apps/currencies/models.py:255
|
||||
#: apps/currencies/models.py:257
|
||||
msgid ""
|
||||
"Invalid format. Please check the requirements for your selected interval "
|
||||
"type."
|
||||
@@ -2727,15 +2727,22 @@ msgstr "Ziel"
|
||||
msgid "Last fetch"
|
||||
msgstr "Letzter Abruf"
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:61
|
||||
#: templates/exchange_rates_services/fragments/list.html:62
|
||||
#, python-format
|
||||
msgid "%(counter)s consecutive failure"
|
||||
msgid_plural "%(counter)s consecutive failures"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
msgid "currencies"
|
||||
msgstr "Währungen"
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:61
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
msgid "accounts"
|
||||
msgstr "Konten"
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
#: templates/exchange_rates_services/fragments/list.html:77
|
||||
msgid "No services configured"
|
||||
msgstr "Keine Dienste konfiguriert"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-01-10 05:53+0000\n"
|
||||
"POT-Creation-Date: 2026-01-10 20:50+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -596,57 +596,57 @@ msgstr ""
|
||||
msgid "Last Successful Fetch"
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:141
|
||||
#: apps/currencies/models.py:143
|
||||
msgid "Target Currencies"
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:143
|
||||
#: apps/currencies/models.py:145
|
||||
msgid ""
|
||||
"Select currencies to fetch exchange rates for. Rates will be fetched for "
|
||||
"each currency against their set exchange currency."
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:151
|
||||
#: apps/currencies/models.py:153
|
||||
msgid "Target Accounts"
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:153
|
||||
#: apps/currencies/models.py:155
|
||||
msgid ""
|
||||
"Select accounts to fetch exchange rates for. Rates will be fetched for each "
|
||||
"account's currency against their set exchange currency."
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:160
|
||||
#: apps/currencies/models.py:162
|
||||
msgid "Single exchange rate"
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:163
|
||||
#: apps/currencies/models.py:165
|
||||
msgid "Create one exchange rate and keep updating it. Avoids database clutter."
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:168
|
||||
#: apps/currencies/models.py:170
|
||||
msgid "Exchange Rate Service"
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:169
|
||||
#: apps/currencies/models.py:171
|
||||
msgid "Exchange Rate Services"
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:221
|
||||
#: apps/currencies/models.py:223
|
||||
msgid "'Every X hours' interval type requires a positive integer."
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:230
|
||||
#: apps/currencies/models.py:232
|
||||
msgid "'Every X hours' interval must be between 1 and 24."
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:244
|
||||
#: apps/currencies/models.py:246
|
||||
msgid ""
|
||||
"Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., "
|
||||
"'1-5,8,10-12')."
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:255
|
||||
#: apps/currencies/models.py:257
|
||||
msgid ""
|
||||
"Invalid format. Please check the requirements for your selected interval "
|
||||
"type."
|
||||
@@ -2649,15 +2649,22 @@ msgstr ""
|
||||
msgid "Last fetch"
|
||||
msgstr ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:61
|
||||
#: templates/exchange_rates_services/fragments/list.html:62
|
||||
#, python-format
|
||||
msgid "%(counter)s consecutive failure"
|
||||
msgid_plural "%(counter)s consecutive failures"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
msgid "currencies"
|
||||
msgstr ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:61
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
msgid "accounts"
|
||||
msgstr ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
#: templates/exchange_rates_services/fragments/list.html:77
|
||||
msgid "No services configured"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-01-10 05:53+0000\n"
|
||||
"POT-Creation-Date: 2026-01-10 20:50+0000\n"
|
||||
"PO-Revision-Date: 2025-12-16 05:24+0000\n"
|
||||
"Last-Translator: BRodolfo <simplysmartbydesign@gmail.com>\n"
|
||||
"Language-Team: Spanish <https://translations.herculino.com/projects/wygiwyh/"
|
||||
@@ -605,11 +605,11 @@ msgstr "Intervalo"
|
||||
msgid "Last Successful Fetch"
|
||||
msgstr "Última Sincronización Exitosa"
|
||||
|
||||
#: apps/currencies/models.py:141
|
||||
#: apps/currencies/models.py:143
|
||||
msgid "Target Currencies"
|
||||
msgstr "Monedas de Destino"
|
||||
|
||||
#: apps/currencies/models.py:143
|
||||
#: apps/currencies/models.py:145
|
||||
msgid ""
|
||||
"Select currencies to fetch exchange rates for. Rates will be fetched for "
|
||||
"each currency against their set exchange currency."
|
||||
@@ -618,11 +618,11 @@ msgstr ""
|
||||
"tasas se consultarán para cada moneda en relación con su moneda de "
|
||||
"referencia establecida."
|
||||
|
||||
#: apps/currencies/models.py:151
|
||||
#: apps/currencies/models.py:153
|
||||
msgid "Target Accounts"
|
||||
msgstr "Cuentas de Destino"
|
||||
|
||||
#: apps/currencies/models.py:153
|
||||
#: apps/currencies/models.py:155
|
||||
msgid ""
|
||||
"Select accounts to fetch exchange rates for. Rates will be fetched for each "
|
||||
"account's currency against their set exchange currency."
|
||||
@@ -631,33 +631,33 @@ msgstr ""
|
||||
"tasas se consultarán para la moneda de cada cuenta en relación con su moneda "
|
||||
"de referencia establecida."
|
||||
|
||||
#: apps/currencies/models.py:160
|
||||
#: apps/currencies/models.py:162
|
||||
msgid "Single exchange rate"
|
||||
msgstr "Tasa de cambio única"
|
||||
|
||||
#: apps/currencies/models.py:163
|
||||
#: apps/currencies/models.py:165
|
||||
msgid "Create one exchange rate and keep updating it. Avoids database clutter."
|
||||
msgstr ""
|
||||
"Crea una única tasa de cambio y mantenla actualizada. Evita la acumulación "
|
||||
"de datos en la base de datos."
|
||||
|
||||
#: apps/currencies/models.py:168
|
||||
#: apps/currencies/models.py:170
|
||||
msgid "Exchange Rate Service"
|
||||
msgstr "Servicio de Tasas de Cambio"
|
||||
|
||||
#: apps/currencies/models.py:169
|
||||
#: apps/currencies/models.py:171
|
||||
msgid "Exchange Rate Services"
|
||||
msgstr "Servicios de Tasas de Cambio"
|
||||
|
||||
#: apps/currencies/models.py:221
|
||||
#: apps/currencies/models.py:223
|
||||
msgid "'Every X hours' interval type requires a positive integer."
|
||||
msgstr "El tipo de intervalo 'Cada X horas' requiere un entero positivo."
|
||||
|
||||
#: apps/currencies/models.py:230
|
||||
#: apps/currencies/models.py:232
|
||||
msgid "'Every X hours' interval must be between 1 and 24."
|
||||
msgstr "El tipo de intervalo 'Cada X horas' debe ser ente 1 y 24."
|
||||
|
||||
#: apps/currencies/models.py:244
|
||||
#: apps/currencies/models.py:246
|
||||
msgid ""
|
||||
"Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., "
|
||||
"'1-5,8,10-12')."
|
||||
@@ -665,7 +665,7 @@ msgstr ""
|
||||
"Formato de hora no válido. Usa horas separadas por coma (0-23) y/o rangos "
|
||||
"(p. ej., \"1-5,8,10-12\")."
|
||||
|
||||
#: apps/currencies/models.py:255
|
||||
#: apps/currencies/models.py:257
|
||||
msgid ""
|
||||
"Invalid format. Please check the requirements for your selected interval "
|
||||
"type."
|
||||
@@ -2692,15 +2692,22 @@ msgstr "Dirigido a"
|
||||
msgid "Last fetch"
|
||||
msgstr "Última sincronización"
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:61
|
||||
#: templates/exchange_rates_services/fragments/list.html:62
|
||||
#, python-format
|
||||
msgid "%(counter)s consecutive failure"
|
||||
msgid_plural "%(counter)s consecutive failures"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
msgid "currencies"
|
||||
msgstr "monedas"
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:61
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
msgid "accounts"
|
||||
msgstr "cuentas"
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
#: templates/exchange_rates_services/fragments/list.html:77
|
||||
msgid "No services configured"
|
||||
msgstr "No hay servicios configurados"
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-01-10 05:53+0000\n"
|
||||
"POT-Creation-Date: 2026-01-10 20:50+0000\n"
|
||||
"PO-Revision-Date: 2025-10-07 20:17+0000\n"
|
||||
"Last-Translator: Erwan Colin <zephone@protonmail.com>\n"
|
||||
"Language-Team: French <https://translations.herculino.com/projects/wygiwyh/"
|
||||
@@ -607,11 +607,11 @@ msgstr "Intervalle"
|
||||
msgid "Last Successful Fetch"
|
||||
msgstr "Dernière récupération avec succès"
|
||||
|
||||
#: apps/currencies/models.py:141
|
||||
#: apps/currencies/models.py:143
|
||||
msgid "Target Currencies"
|
||||
msgstr "Devises cibles"
|
||||
|
||||
#: apps/currencies/models.py:143
|
||||
#: apps/currencies/models.py:145
|
||||
msgid ""
|
||||
"Select currencies to fetch exchange rates for. Rates will be fetched for "
|
||||
"each currency against their set exchange currency."
|
||||
@@ -619,11 +619,11 @@ msgstr ""
|
||||
"Sélectionnez les devises pour récupérer leur taux de changes. Les taux "
|
||||
"seront récupérés pour chaque devises par rapport à leur devise d'échange."
|
||||
|
||||
#: apps/currencies/models.py:151
|
||||
#: apps/currencies/models.py:153
|
||||
msgid "Target Accounts"
|
||||
msgstr "Comptes cibles"
|
||||
|
||||
#: apps/currencies/models.py:153
|
||||
#: apps/currencies/models.py:155
|
||||
msgid ""
|
||||
"Select accounts to fetch exchange rates for. Rates will be fetched for each "
|
||||
"account's currency against their set exchange currency."
|
||||
@@ -631,33 +631,33 @@ msgstr ""
|
||||
"Sélectionnez les comptes pour récupérer leur taux de change. Les taux seront "
|
||||
"récupérés pour chaque compte par rapport à leur devise d'échange."
|
||||
|
||||
#: apps/currencies/models.py:160
|
||||
#: apps/currencies/models.py:162
|
||||
msgid "Single exchange rate"
|
||||
msgstr "Taux de change unique"
|
||||
|
||||
#: apps/currencies/models.py:163
|
||||
#: apps/currencies/models.py:165
|
||||
msgid "Create one exchange rate and keep updating it. Avoids database clutter."
|
||||
msgstr ""
|
||||
"Ne créer qu'un seul taux de change et le mettre à jour. Evite d'engorger la "
|
||||
"base de donnée."
|
||||
|
||||
#: apps/currencies/models.py:168
|
||||
#: apps/currencies/models.py:170
|
||||
msgid "Exchange Rate Service"
|
||||
msgstr "Service de taux de change"
|
||||
|
||||
#: apps/currencies/models.py:169
|
||||
#: apps/currencies/models.py:171
|
||||
msgid "Exchange Rate Services"
|
||||
msgstr "Services de taux de change"
|
||||
|
||||
#: apps/currencies/models.py:221
|
||||
#: apps/currencies/models.py:223
|
||||
msgid "'Every X hours' interval type requires a positive integer."
|
||||
msgstr "'Toutes les X heures' l'intervalle requiert un entier positif."
|
||||
|
||||
#: apps/currencies/models.py:230
|
||||
#: apps/currencies/models.py:232
|
||||
msgid "'Every X hours' interval must be between 1 and 24."
|
||||
msgstr "'Toutes les X heures' l'intervalle doit être compris entre 1 et 24."
|
||||
|
||||
#: apps/currencies/models.py:244
|
||||
#: apps/currencies/models.py:246
|
||||
msgid ""
|
||||
"Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., "
|
||||
"'1-5,8,10-12')."
|
||||
@@ -665,7 +665,7 @@ msgstr ""
|
||||
"Format d'heure invalide. Utilisez les heures séparé par virgule (0-23) et/ou "
|
||||
"une plage (ex : '1-5,8,10-12')."
|
||||
|
||||
#: apps/currencies/models.py:255
|
||||
#: apps/currencies/models.py:257
|
||||
msgid ""
|
||||
"Invalid format. Please check the requirements for your selected interval "
|
||||
"type."
|
||||
@@ -2707,15 +2707,22 @@ msgstr "Ciblage"
|
||||
msgid "Last fetch"
|
||||
msgstr "Dernière récupération"
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:61
|
||||
#: templates/exchange_rates_services/fragments/list.html:62
|
||||
#, python-format
|
||||
msgid "%(counter)s consecutive failure"
|
||||
msgid_plural "%(counter)s consecutive failures"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
msgid "currencies"
|
||||
msgstr "devises"
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:61
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
msgid "accounts"
|
||||
msgstr "Comptes"
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
#: templates/exchange_rates_services/fragments/list.html:77
|
||||
msgid "No services configured"
|
||||
msgstr "Pas de services configurés"
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-01-10 05:53+0000\n"
|
||||
"POT-Creation-Date: 2026-01-10 20:50+0000\n"
|
||||
"PO-Revision-Date: 2026-01-10 03:09+0000\n"
|
||||
"Last-Translator: Herculino Trotta <netotrotta@gmail.com>\n"
|
||||
"Language-Team: Hungarian <https://translations.herculino.com/projects/"
|
||||
@@ -602,57 +602,57 @@ msgstr "Intervallum"
|
||||
msgid "Last Successful Fetch"
|
||||
msgstr "Utolsó sikeres elérés"
|
||||
|
||||
#: apps/currencies/models.py:141
|
||||
#: apps/currencies/models.py:143
|
||||
msgid "Target Currencies"
|
||||
msgstr "Cél pénznemek"
|
||||
|
||||
#: apps/currencies/models.py:143
|
||||
#: apps/currencies/models.py:145
|
||||
msgid ""
|
||||
"Select currencies to fetch exchange rates for. Rates will be fetched for "
|
||||
"each currency against their set exchange currency."
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:151
|
||||
#: apps/currencies/models.py:153
|
||||
msgid "Target Accounts"
|
||||
msgstr "Cél számlák"
|
||||
|
||||
#: apps/currencies/models.py:153
|
||||
#: apps/currencies/models.py:155
|
||||
msgid ""
|
||||
"Select accounts to fetch exchange rates for. Rates will be fetched for each "
|
||||
"account's currency against their set exchange currency."
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:160
|
||||
#: apps/currencies/models.py:162
|
||||
msgid "Single exchange rate"
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:163
|
||||
#: apps/currencies/models.py:165
|
||||
msgid "Create one exchange rate and keep updating it. Avoids database clutter."
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:168
|
||||
#: apps/currencies/models.py:170
|
||||
msgid "Exchange Rate Service"
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:169
|
||||
#: apps/currencies/models.py:171
|
||||
msgid "Exchange Rate Services"
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:221
|
||||
#: apps/currencies/models.py:223
|
||||
msgid "'Every X hours' interval type requires a positive integer."
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:230
|
||||
#: apps/currencies/models.py:232
|
||||
msgid "'Every X hours' interval must be between 1 and 24."
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:244
|
||||
#: apps/currencies/models.py:246
|
||||
msgid ""
|
||||
"Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., "
|
||||
"'1-5,8,10-12')."
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:255
|
||||
#: apps/currencies/models.py:257
|
||||
msgid ""
|
||||
"Invalid format. Please check the requirements for your selected interval "
|
||||
"type."
|
||||
@@ -2655,15 +2655,22 @@ msgstr ""
|
||||
msgid "Last fetch"
|
||||
msgstr ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:61
|
||||
#: templates/exchange_rates_services/fragments/list.html:62
|
||||
#, python-format
|
||||
msgid "%(counter)s consecutive failure"
|
||||
msgid_plural "%(counter)s consecutive failures"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
msgid "currencies"
|
||||
msgstr ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:61
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
msgid "accounts"
|
||||
msgstr ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
#: templates/exchange_rates_services/fragments/list.html:77
|
||||
msgid "No services configured"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-01-10 05:53+0000\n"
|
||||
"POT-Creation-Date: 2026-01-10 20:50+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: Automatically generated\n"
|
||||
"Language-Team: none\n"
|
||||
@@ -595,57 +595,57 @@ msgstr ""
|
||||
msgid "Last Successful Fetch"
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:141
|
||||
#: apps/currencies/models.py:143
|
||||
msgid "Target Currencies"
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:143
|
||||
#: apps/currencies/models.py:145
|
||||
msgid ""
|
||||
"Select currencies to fetch exchange rates for. Rates will be fetched for "
|
||||
"each currency against their set exchange currency."
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:151
|
||||
#: apps/currencies/models.py:153
|
||||
msgid "Target Accounts"
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:153
|
||||
#: apps/currencies/models.py:155
|
||||
msgid ""
|
||||
"Select accounts to fetch exchange rates for. Rates will be fetched for each "
|
||||
"account's currency against their set exchange currency."
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:160
|
||||
#: apps/currencies/models.py:162
|
||||
msgid "Single exchange rate"
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:163
|
||||
#: apps/currencies/models.py:165
|
||||
msgid "Create one exchange rate and keep updating it. Avoids database clutter."
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:168
|
||||
#: apps/currencies/models.py:170
|
||||
msgid "Exchange Rate Service"
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:169
|
||||
#: apps/currencies/models.py:171
|
||||
msgid "Exchange Rate Services"
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:221
|
||||
#: apps/currencies/models.py:223
|
||||
msgid "'Every X hours' interval type requires a positive integer."
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:230
|
||||
#: apps/currencies/models.py:232
|
||||
msgid "'Every X hours' interval must be between 1 and 24."
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:244
|
||||
#: apps/currencies/models.py:246
|
||||
msgid ""
|
||||
"Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., "
|
||||
"'1-5,8,10-12')."
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:255
|
||||
#: apps/currencies/models.py:257
|
||||
msgid ""
|
||||
"Invalid format. Please check the requirements for your selected interval "
|
||||
"type."
|
||||
@@ -2648,15 +2648,21 @@ msgstr ""
|
||||
msgid "Last fetch"
|
||||
msgstr ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:61
|
||||
#: templates/exchange_rates_services/fragments/list.html:62
|
||||
#, python-format
|
||||
msgid "%(counter)s consecutive failure"
|
||||
msgid_plural "%(counter)s consecutive failures"
|
||||
msgstr[0] ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
msgid "currencies"
|
||||
msgstr ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:61
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
msgid "accounts"
|
||||
msgstr ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
#: templates/exchange_rates_services/fragments/list.html:77
|
||||
msgid "No services configured"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-01-10 05:53+0000\n"
|
||||
"POT-Creation-Date: 2026-01-10 20:50+0000\n"
|
||||
"PO-Revision-Date: 2025-12-28 22:24+0000\n"
|
||||
"Last-Translator: icovada <federico.tabbo@networktocode.com>\n"
|
||||
"Language-Team: Italian <https://translations.herculino.com/projects/wygiwyh/"
|
||||
@@ -607,11 +607,11 @@ msgstr "Intervallo"
|
||||
msgid "Last Successful Fetch"
|
||||
msgstr "Ultimo scaricamento riuscito"
|
||||
|
||||
#: apps/currencies/models.py:141
|
||||
#: apps/currencies/models.py:143
|
||||
msgid "Target Currencies"
|
||||
msgstr "Valute target"
|
||||
|
||||
#: apps/currencies/models.py:143
|
||||
#: apps/currencies/models.py:145
|
||||
msgid ""
|
||||
"Select currencies to fetch exchange rates for. Rates will be fetched for "
|
||||
"each currency against their set exchange currency."
|
||||
@@ -619,11 +619,11 @@ msgstr ""
|
||||
"Seleziona le valute per cui scaricare i tassi di cambio. I tassi verranno "
|
||||
"scaricati per ogni valuta rispetto alla valuta di cambio impostata."
|
||||
|
||||
#: apps/currencies/models.py:151
|
||||
#: apps/currencies/models.py:153
|
||||
msgid "Target Accounts"
|
||||
msgstr "Conti target"
|
||||
|
||||
#: apps/currencies/models.py:153
|
||||
#: apps/currencies/models.py:155
|
||||
msgid ""
|
||||
"Select accounts to fetch exchange rates for. Rates will be fetched for each "
|
||||
"account's currency against their set exchange currency."
|
||||
@@ -632,33 +632,33 @@ msgstr ""
|
||||
"scaricati per la valuta di ogni conto rispetto alla valuta di cambio "
|
||||
"impostata."
|
||||
|
||||
#: apps/currencies/models.py:160
|
||||
#: apps/currencies/models.py:162
|
||||
msgid "Single exchange rate"
|
||||
msgstr "Cambio unico"
|
||||
|
||||
#: apps/currencies/models.py:163
|
||||
#: apps/currencies/models.py:165
|
||||
msgid "Create one exchange rate and keep updating it. Avoids database clutter."
|
||||
msgstr ""
|
||||
"Crea un solo cambio valuta e aggiornalo nel tempo, evitando duplicati nel "
|
||||
"database."
|
||||
|
||||
#: apps/currencies/models.py:168
|
||||
#: apps/currencies/models.py:170
|
||||
msgid "Exchange Rate Service"
|
||||
msgstr "Servizio cambio valuta"
|
||||
|
||||
#: apps/currencies/models.py:169
|
||||
#: apps/currencies/models.py:171
|
||||
msgid "Exchange Rate Services"
|
||||
msgstr "Servizi tassi di cambio"
|
||||
|
||||
#: apps/currencies/models.py:221
|
||||
#: apps/currencies/models.py:223
|
||||
msgid "'Every X hours' interval type requires a positive integer."
|
||||
msgstr "L’intervallo “Ogni X ore” richiede un numero intero positivo."
|
||||
|
||||
#: apps/currencies/models.py:230
|
||||
#: apps/currencies/models.py:232
|
||||
msgid "'Every X hours' interval must be between 1 and 24."
|
||||
msgstr "L’intervallo “Ogni X ore” deve essere compreso tra 1 e 24."
|
||||
|
||||
#: apps/currencies/models.py:244
|
||||
#: apps/currencies/models.py:246
|
||||
msgid ""
|
||||
"Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., "
|
||||
"'1-5,8,10-12')."
|
||||
@@ -666,7 +666,7 @@ msgstr ""
|
||||
"Formato ore non valido. Usa ore separate da virgole (0–23) e/o intervalli "
|
||||
"(es. '1-5,8,10-12')."
|
||||
|
||||
#: apps/currencies/models.py:255
|
||||
#: apps/currencies/models.py:257
|
||||
msgid ""
|
||||
"Invalid format. Please check the requirements for your selected interval "
|
||||
"type."
|
||||
@@ -2698,15 +2698,22 @@ msgstr "Targeting"
|
||||
msgid "Last fetch"
|
||||
msgstr "Ultimo recupero"
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:61
|
||||
#: templates/exchange_rates_services/fragments/list.html:62
|
||||
#, python-format
|
||||
msgid "%(counter)s consecutive failure"
|
||||
msgid_plural "%(counter)s consecutive failures"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
msgid "currencies"
|
||||
msgstr "valute"
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:61
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
msgid "accounts"
|
||||
msgstr "conti"
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
#: templates/exchange_rates_services/fragments/list.html:77
|
||||
msgid "No services configured"
|
||||
msgstr "Nessun servizio configurato"
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-01-10 05:53+0000\n"
|
||||
"POT-Creation-Date: 2026-01-10 20:50+0000\n"
|
||||
"PO-Revision-Date: 2025-12-29 07:24+0000\n"
|
||||
"Last-Translator: Dimitri Decrock <dj.flashpower@gmail.com>\n"
|
||||
"Language-Team: Dutch <https://translations.herculino.com/projects/wygiwyh/"
|
||||
@@ -607,11 +607,11 @@ msgstr "Interval"
|
||||
msgid "Last Successful Fetch"
|
||||
msgstr "Laatste Succesvolle Ophaling"
|
||||
|
||||
#: apps/currencies/models.py:141
|
||||
#: apps/currencies/models.py:143
|
||||
msgid "Target Currencies"
|
||||
msgstr "Doel Munteenheden"
|
||||
|
||||
#: apps/currencies/models.py:143
|
||||
#: apps/currencies/models.py:145
|
||||
msgid ""
|
||||
"Select currencies to fetch exchange rates for. Rates will be fetched for "
|
||||
"each currency against their set exchange currency."
|
||||
@@ -619,11 +619,11 @@ msgstr ""
|
||||
"Selecteer munteenheden om wisselkoersen voor op te halen. De koersen worden "
|
||||
"voor elke munteenheid opgehaald ten opzichte van de ingestelde wisselkoers."
|
||||
|
||||
#: apps/currencies/models.py:151
|
||||
#: apps/currencies/models.py:153
|
||||
msgid "Target Accounts"
|
||||
msgstr "Naar rekeningen"
|
||||
|
||||
#: apps/currencies/models.py:153
|
||||
#: apps/currencies/models.py:155
|
||||
msgid ""
|
||||
"Select accounts to fetch exchange rates for. Rates will be fetched for each "
|
||||
"account's currency against their set exchange currency."
|
||||
@@ -632,33 +632,33 @@ msgstr ""
|
||||
"opgehaald voor de munteenheid van elke rekening ten opzichte van de "
|
||||
"ingestelde wisselkoers."
|
||||
|
||||
#: apps/currencies/models.py:160
|
||||
#: apps/currencies/models.py:162
|
||||
msgid "Single exchange rate"
|
||||
msgstr "Enkele Wisselkoers"
|
||||
|
||||
#: apps/currencies/models.py:163
|
||||
#: apps/currencies/models.py:165
|
||||
msgid "Create one exchange rate and keep updating it. Avoids database clutter."
|
||||
msgstr ""
|
||||
"Maak één wisselkoers aan en houd deze bijgewerkt. Voorkomt een overvolle "
|
||||
"database."
|
||||
|
||||
#: apps/currencies/models.py:168
|
||||
#: apps/currencies/models.py:170
|
||||
msgid "Exchange Rate Service"
|
||||
msgstr "Wisselkoersdienst"
|
||||
|
||||
#: apps/currencies/models.py:169
|
||||
#: apps/currencies/models.py:171
|
||||
msgid "Exchange Rate Services"
|
||||
msgstr "Wisselkoersdiensten"
|
||||
|
||||
#: apps/currencies/models.py:221
|
||||
#: apps/currencies/models.py:223
|
||||
msgid "'Every X hours' interval type requires a positive integer."
|
||||
msgstr "Voor het intervaltype ‘Elke X uur’ is een positief geheel getal nodig."
|
||||
|
||||
#: apps/currencies/models.py:230
|
||||
#: apps/currencies/models.py:232
|
||||
msgid "'Every X hours' interval must be between 1 and 24."
|
||||
msgstr "Het interval ‘Elke X uur’ moet tussen 1 en 24 liggen."
|
||||
|
||||
#: apps/currencies/models.py:244
|
||||
#: apps/currencies/models.py:246
|
||||
msgid ""
|
||||
"Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., "
|
||||
"'1-5,8,10-12')."
|
||||
@@ -666,7 +666,7 @@ msgstr ""
|
||||
"Ongeldige urennotatie. Gebruik door komma's gescheiden uren (0-23) en/of "
|
||||
"reeksen (bijv. ‘1-5,8,10-12’)."
|
||||
|
||||
#: apps/currencies/models.py:255
|
||||
#: apps/currencies/models.py:257
|
||||
msgid ""
|
||||
"Invalid format. Please check the requirements for your selected interval "
|
||||
"type."
|
||||
@@ -2689,15 +2689,22 @@ msgstr "Gericht op"
|
||||
msgid "Last fetch"
|
||||
msgstr "Laatst opgehaald"
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:61
|
||||
#: templates/exchange_rates_services/fragments/list.html:62
|
||||
#, python-format
|
||||
msgid "%(counter)s consecutive failure"
|
||||
msgid_plural "%(counter)s consecutive failures"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
msgid "currencies"
|
||||
msgstr "munteenheden"
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:61
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
msgid "accounts"
|
||||
msgstr "rekeningen"
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
#: templates/exchange_rates_services/fragments/list.html:77
|
||||
msgid "No services configured"
|
||||
msgstr "Geen diensten ingesteld"
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-01-10 05:53+0000\n"
|
||||
"POT-Creation-Date: 2026-01-10 20:50+0000\n"
|
||||
"PO-Revision-Date: 2025-11-08 12:20+0000\n"
|
||||
"Last-Translator: Marcin Kisielewski <kisielewski.mar@gmail.com>\n"
|
||||
"Language-Team: Polish <https://translations.herculino.com/projects/wygiwyh/"
|
||||
@@ -598,57 +598,57 @@ msgstr ""
|
||||
msgid "Last Successful Fetch"
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:141
|
||||
#: apps/currencies/models.py:143
|
||||
msgid "Target Currencies"
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:143
|
||||
#: apps/currencies/models.py:145
|
||||
msgid ""
|
||||
"Select currencies to fetch exchange rates for. Rates will be fetched for "
|
||||
"each currency against their set exchange currency."
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:151
|
||||
#: apps/currencies/models.py:153
|
||||
msgid "Target Accounts"
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:153
|
||||
#: apps/currencies/models.py:155
|
||||
msgid ""
|
||||
"Select accounts to fetch exchange rates for. Rates will be fetched for each "
|
||||
"account's currency against their set exchange currency."
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:160
|
||||
#: apps/currencies/models.py:162
|
||||
msgid "Single exchange rate"
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:163
|
||||
#: apps/currencies/models.py:165
|
||||
msgid "Create one exchange rate and keep updating it. Avoids database clutter."
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:168
|
||||
#: apps/currencies/models.py:170
|
||||
msgid "Exchange Rate Service"
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:169
|
||||
#: apps/currencies/models.py:171
|
||||
msgid "Exchange Rate Services"
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:221
|
||||
#: apps/currencies/models.py:223
|
||||
msgid "'Every X hours' interval type requires a positive integer."
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:230
|
||||
#: apps/currencies/models.py:232
|
||||
msgid "'Every X hours' interval must be between 1 and 24."
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:244
|
||||
#: apps/currencies/models.py:246
|
||||
msgid ""
|
||||
"Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., "
|
||||
"'1-5,8,10-12')."
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:255
|
||||
#: apps/currencies/models.py:257
|
||||
msgid ""
|
||||
"Invalid format. Please check the requirements for your selected interval "
|
||||
"type."
|
||||
@@ -2651,15 +2651,23 @@ msgstr ""
|
||||
msgid "Last fetch"
|
||||
msgstr ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:61
|
||||
#: templates/exchange_rates_services/fragments/list.html:62
|
||||
#, python-format
|
||||
msgid "%(counter)s consecutive failure"
|
||||
msgid_plural "%(counter)s consecutive failures"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
msgstr[2] ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
msgid "currencies"
|
||||
msgstr ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:61
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
msgid "accounts"
|
||||
msgstr ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
#: templates/exchange_rates_services/fragments/list.html:77
|
||||
msgid "No services configured"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-01-10 05:53+0000\n"
|
||||
"POT-Creation-Date: 2026-01-10 20:50+0000\n"
|
||||
"PO-Revision-Date: 2025-12-29 02:24+0000\n"
|
||||
"Last-Translator: Herculino Trotta <netotrotta@gmail.com>\n"
|
||||
"Language-Team: Portuguese (Brazil) <https://translations.herculino.com/"
|
||||
@@ -605,11 +605,11 @@ msgstr "Intervalo"
|
||||
msgid "Last Successful Fetch"
|
||||
msgstr "Última execução bem-sucedida"
|
||||
|
||||
#: apps/currencies/models.py:141
|
||||
#: apps/currencies/models.py:143
|
||||
msgid "Target Currencies"
|
||||
msgstr "Moedas-alvo"
|
||||
|
||||
#: apps/currencies/models.py:143
|
||||
#: apps/currencies/models.py:145
|
||||
msgid ""
|
||||
"Select currencies to fetch exchange rates for. Rates will be fetched for "
|
||||
"each currency against their set exchange currency."
|
||||
@@ -617,11 +617,11 @@ msgstr ""
|
||||
"Selecione as moedas para as quais deseja obter as taxas de câmbio. As taxas "
|
||||
"serão obtidas para cada moeda em relação à moeda de câmbio definida."
|
||||
|
||||
#: apps/currencies/models.py:151
|
||||
#: apps/currencies/models.py:153
|
||||
msgid "Target Accounts"
|
||||
msgstr "Contas-alvo"
|
||||
|
||||
#: apps/currencies/models.py:153
|
||||
#: apps/currencies/models.py:155
|
||||
msgid ""
|
||||
"Select accounts to fetch exchange rates for. Rates will be fetched for each "
|
||||
"account's currency against their set exchange currency."
|
||||
@@ -630,34 +630,34 @@ msgstr ""
|
||||
"serão obtidas para a moeda de cada conta em relação à moeda de câmbio "
|
||||
"definida."
|
||||
|
||||
#: apps/currencies/models.py:160
|
||||
#: apps/currencies/models.py:162
|
||||
msgid "Single exchange rate"
|
||||
msgstr "Taxa de câmbio única"
|
||||
|
||||
#: apps/currencies/models.py:163
|
||||
#: apps/currencies/models.py:165
|
||||
msgid "Create one exchange rate and keep updating it. Avoids database clutter."
|
||||
msgstr ""
|
||||
"Cria uma taxa de câmbio e mantenha-a atualizada. Evita a poluição do banco "
|
||||
"de dados."
|
||||
|
||||
#: apps/currencies/models.py:168
|
||||
#: apps/currencies/models.py:170
|
||||
msgid "Exchange Rate Service"
|
||||
msgstr "Serviço de Taxa de Câmbio"
|
||||
|
||||
#: apps/currencies/models.py:169
|
||||
#: apps/currencies/models.py:171
|
||||
msgid "Exchange Rate Services"
|
||||
msgstr "Serviços de Taxa de Câmbio"
|
||||
|
||||
#: apps/currencies/models.py:221
|
||||
#: apps/currencies/models.py:223
|
||||
msgid "'Every X hours' interval type requires a positive integer."
|
||||
msgstr ""
|
||||
"Intervalo do tipo 'A cada X horas' requerer um número inteiro positivo."
|
||||
|
||||
#: apps/currencies/models.py:230
|
||||
#: apps/currencies/models.py:232
|
||||
msgid "'Every X hours' interval must be between 1 and 24."
|
||||
msgstr "Intervalo do tipo 'A cada X horas' requerer um número entre 1 e 24."
|
||||
|
||||
#: apps/currencies/models.py:244
|
||||
#: apps/currencies/models.py:246
|
||||
msgid ""
|
||||
"Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., "
|
||||
"'1-5,8,10-12')."
|
||||
@@ -665,7 +665,7 @@ msgstr ""
|
||||
"Formato inválido de hora. Use uma lista de horas separada por vírgulas "
|
||||
"(0-23) e/ou uma faixa (ex.: '1-5,8,10-12')."
|
||||
|
||||
#: apps/currencies/models.py:255
|
||||
#: apps/currencies/models.py:257
|
||||
msgid ""
|
||||
"Invalid format. Please check the requirements for your selected interval "
|
||||
"type."
|
||||
@@ -2689,15 +2689,22 @@ msgstr "Alvos"
|
||||
msgid "Last fetch"
|
||||
msgstr "Última execução"
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:61
|
||||
#: templates/exchange_rates_services/fragments/list.html:62
|
||||
#, python-format
|
||||
msgid "%(counter)s consecutive failure"
|
||||
msgid_plural "%(counter)s consecutive failures"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
msgid "currencies"
|
||||
msgstr "moedas"
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:61
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
msgid "accounts"
|
||||
msgstr "contas"
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
#: templates/exchange_rates_services/fragments/list.html:77
|
||||
msgid "No services configured"
|
||||
msgstr "Nenhum serviço configurado"
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-01-10 05:53+0000\n"
|
||||
"POT-Creation-Date: 2026-01-10 20:50+0000\n"
|
||||
"PO-Revision-Date: 2025-04-14 06:16+0000\n"
|
||||
"Last-Translator: Emil <emil.bjorkroth@gmail.com>\n"
|
||||
"Language-Team: Swedish <https://translations.herculino.com/projects/wygiwyh/"
|
||||
@@ -597,57 +597,57 @@ msgstr ""
|
||||
msgid "Last Successful Fetch"
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:141
|
||||
#: apps/currencies/models.py:143
|
||||
msgid "Target Currencies"
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:143
|
||||
#: apps/currencies/models.py:145
|
||||
msgid ""
|
||||
"Select currencies to fetch exchange rates for. Rates will be fetched for "
|
||||
"each currency against their set exchange currency."
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:151
|
||||
#: apps/currencies/models.py:153
|
||||
msgid "Target Accounts"
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:153
|
||||
#: apps/currencies/models.py:155
|
||||
msgid ""
|
||||
"Select accounts to fetch exchange rates for. Rates will be fetched for each "
|
||||
"account's currency against their set exchange currency."
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:160
|
||||
#: apps/currencies/models.py:162
|
||||
msgid "Single exchange rate"
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:163
|
||||
#: apps/currencies/models.py:165
|
||||
msgid "Create one exchange rate and keep updating it. Avoids database clutter."
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:168
|
||||
#: apps/currencies/models.py:170
|
||||
msgid "Exchange Rate Service"
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:169
|
||||
#: apps/currencies/models.py:171
|
||||
msgid "Exchange Rate Services"
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:221
|
||||
#: apps/currencies/models.py:223
|
||||
msgid "'Every X hours' interval type requires a positive integer."
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:230
|
||||
#: apps/currencies/models.py:232
|
||||
msgid "'Every X hours' interval must be between 1 and 24."
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:244
|
||||
#: apps/currencies/models.py:246
|
||||
msgid ""
|
||||
"Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., "
|
||||
"'1-5,8,10-12')."
|
||||
msgstr ""
|
||||
|
||||
#: apps/currencies/models.py:255
|
||||
#: apps/currencies/models.py:257
|
||||
msgid ""
|
||||
"Invalid format. Please check the requirements for your selected interval "
|
||||
"type."
|
||||
@@ -2650,15 +2650,22 @@ msgstr ""
|
||||
msgid "Last fetch"
|
||||
msgstr ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:61
|
||||
#: templates/exchange_rates_services/fragments/list.html:62
|
||||
#, python-format
|
||||
msgid "%(counter)s consecutive failure"
|
||||
msgid_plural "%(counter)s consecutive failures"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
msgid "currencies"
|
||||
msgstr ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:61
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
msgid "accounts"
|
||||
msgstr ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
#: templates/exchange_rates_services/fragments/list.html:77
|
||||
msgid "No services configured"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-01-10 05:53+0000\n"
|
||||
"POT-Creation-Date: 2026-01-10 20:50+0000\n"
|
||||
"PO-Revision-Date: 2025-11-01 01:17+0000\n"
|
||||
"Last-Translator: mlystopad <mlystopadt@gmail.com>\n"
|
||||
"Language-Team: Ukrainian <https://translations.herculino.com/projects/"
|
||||
@@ -615,11 +615,11 @@ msgstr "Інтервал"
|
||||
msgid "Last Successful Fetch"
|
||||
msgstr "Остання успішна вибірка"
|
||||
|
||||
#: apps/currencies/models.py:141
|
||||
#: apps/currencies/models.py:143
|
||||
msgid "Target Currencies"
|
||||
msgstr "Цільові валюти"
|
||||
|
||||
#: apps/currencies/models.py:143
|
||||
#: apps/currencies/models.py:145
|
||||
msgid ""
|
||||
"Select currencies to fetch exchange rates for. Rates will be fetched for "
|
||||
"each currency against their set exchange currency."
|
||||
@@ -627,11 +627,11 @@ msgstr ""
|
||||
"Оберіть валюти для завантаження курсів обміну. Курси будуть завантажені для "
|
||||
"кожної валюти відносно встановленої валюти обміну."
|
||||
|
||||
#: apps/currencies/models.py:151
|
||||
#: apps/currencies/models.py:153
|
||||
msgid "Target Accounts"
|
||||
msgstr "Цільові Рахунки"
|
||||
|
||||
#: apps/currencies/models.py:153
|
||||
#: apps/currencies/models.py:155
|
||||
msgid ""
|
||||
"Select accounts to fetch exchange rates for. Rates will be fetched for each "
|
||||
"account's currency against their set exchange currency."
|
||||
@@ -639,35 +639,35 @@ msgstr ""
|
||||
"Оберіть рахунки для завантаження курсів обміну. Курси будуть завантажені для "
|
||||
"валюти кожного рахунку відносно встановленої валюти обміну."
|
||||
|
||||
#: apps/currencies/models.py:160
|
||||
#: apps/currencies/models.py:162
|
||||
#, fuzzy
|
||||
#| msgid "Exchange Rate"
|
||||
msgid "Single exchange rate"
|
||||
msgstr "Обмінний курс"
|
||||
|
||||
#: apps/currencies/models.py:163
|
||||
#: apps/currencies/models.py:165
|
||||
msgid "Create one exchange rate and keep updating it. Avoids database clutter."
|
||||
msgstr ""
|
||||
"Створіть один курс обміну та оновлюйте його постійно. Це запобігає "
|
||||
"засміченню бази даних."
|
||||
|
||||
#: apps/currencies/models.py:168
|
||||
#: apps/currencies/models.py:170
|
||||
msgid "Exchange Rate Service"
|
||||
msgstr "Сервіс Курсів Обміну"
|
||||
|
||||
#: apps/currencies/models.py:169
|
||||
#: apps/currencies/models.py:171
|
||||
msgid "Exchange Rate Services"
|
||||
msgstr "Сервіси Курсів Обміну"
|
||||
|
||||
#: apps/currencies/models.py:221
|
||||
#: apps/currencies/models.py:223
|
||||
msgid "'Every X hours' interval type requires a positive integer."
|
||||
msgstr "Інтервал типу «Кожні X годин» потребує додатнього цілого числа."
|
||||
|
||||
#: apps/currencies/models.py:230
|
||||
#: apps/currencies/models.py:232
|
||||
msgid "'Every X hours' interval must be between 1 and 24."
|
||||
msgstr "Інтервал типу «Кожні X годин» повинен бути між 1 та 24."
|
||||
|
||||
#: apps/currencies/models.py:244
|
||||
#: apps/currencies/models.py:246
|
||||
msgid ""
|
||||
"Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., "
|
||||
"'1-5,8,10-12')."
|
||||
@@ -675,7 +675,7 @@ msgstr ""
|
||||
"Неправильний формат годин. Використовуйте години, розділені комами (0–23) та/"
|
||||
"або діапазони (наприклад, '1-5,8,10-12')."
|
||||
|
||||
#: apps/currencies/models.py:255
|
||||
#: apps/currencies/models.py:257
|
||||
msgid ""
|
||||
"Invalid format. Please check the requirements for your selected interval "
|
||||
"type."
|
||||
@@ -2686,15 +2686,23 @@ msgstr ""
|
||||
msgid "Last fetch"
|
||||
msgstr ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:61
|
||||
#: templates/exchange_rates_services/fragments/list.html:62
|
||||
#, python-format
|
||||
msgid "%(counter)s consecutive failure"
|
||||
msgid_plural "%(counter)s consecutive failures"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
msgstr[2] ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
msgid "currencies"
|
||||
msgstr ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:61
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
msgid "accounts"
|
||||
msgstr ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
#: templates/exchange_rates_services/fragments/list.html:77
|
||||
msgid "No services configured"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-01-10 05:53+0000\n"
|
||||
"POT-Creation-Date: 2026-01-10 20:50+0000\n"
|
||||
"PO-Revision-Date: 2025-10-08 16:17+0000\n"
|
||||
"Last-Translator: doody <doodykimo@gmail.com>\n"
|
||||
"Language-Team: Chinese (Traditional Han script) <https://translations."
|
||||
@@ -595,51 +595,51 @@ msgstr "間隔"
|
||||
msgid "Last Successful Fetch"
|
||||
msgstr "最後更新時間"
|
||||
|
||||
#: apps/currencies/models.py:141
|
||||
#: apps/currencies/models.py:143
|
||||
msgid "Target Currencies"
|
||||
msgstr "目標貨幣"
|
||||
|
||||
#: apps/currencies/models.py:143
|
||||
#: apps/currencies/models.py:145
|
||||
msgid ""
|
||||
"Select currencies to fetch exchange rates for. Rates will be fetched for "
|
||||
"each currency against their set exchange currency."
|
||||
msgstr "選擇要自動擷取匯率的貨幣,貨幣會根據設定的目標貨幣自動取得匯率資訊。"
|
||||
|
||||
#: apps/currencies/models.py:151
|
||||
#: apps/currencies/models.py:153
|
||||
msgid "Target Accounts"
|
||||
msgstr "目標帳戶"
|
||||
|
||||
#: apps/currencies/models.py:153
|
||||
#: apps/currencies/models.py:155
|
||||
msgid ""
|
||||
"Select accounts to fetch exchange rates for. Rates will be fetched for each "
|
||||
"account's currency against their set exchange currency."
|
||||
msgstr "選擇自動擷取匯率的帳戶,帳戶會根據設定的匯兌貨幣取得匯率資訊。"
|
||||
|
||||
#: apps/currencies/models.py:160
|
||||
#: apps/currencies/models.py:162
|
||||
msgid "Single exchange rate"
|
||||
msgstr "保留單一匯率資訊"
|
||||
|
||||
#: apps/currencies/models.py:163
|
||||
#: apps/currencies/models.py:165
|
||||
msgid "Create one exchange rate and keep updating it. Avoids database clutter."
|
||||
msgstr "只建立一筆匯率資訊並且持續更新,防止資料庫無限擴張。"
|
||||
|
||||
#: apps/currencies/models.py:168
|
||||
#: apps/currencies/models.py:170
|
||||
msgid "Exchange Rate Service"
|
||||
msgstr "匯率資訊服務"
|
||||
|
||||
#: apps/currencies/models.py:169
|
||||
#: apps/currencies/models.py:171
|
||||
msgid "Exchange Rate Services"
|
||||
msgstr "匯率資訊服務"
|
||||
|
||||
#: apps/currencies/models.py:221
|
||||
#: apps/currencies/models.py:223
|
||||
msgid "'Every X hours' interval type requires a positive integer."
|
||||
msgstr "「每X小時」需要提供正整數。"
|
||||
|
||||
#: apps/currencies/models.py:230
|
||||
#: apps/currencies/models.py:232
|
||||
msgid "'Every X hours' interval must be between 1 and 24."
|
||||
msgstr "「每X小時」需要介於1到24之間。"
|
||||
|
||||
#: apps/currencies/models.py:244
|
||||
#: apps/currencies/models.py:246
|
||||
msgid ""
|
||||
"Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., "
|
||||
"'1-5,8,10-12')."
|
||||
@@ -647,7 +647,7 @@ msgstr ""
|
||||
"錯誤的小時格式,請使用逗號設定多個小時(0~23)或著設定範圍(例"
|
||||
"如:'1-5,10-12')。"
|
||||
|
||||
#: apps/currencies/models.py:255
|
||||
#: apps/currencies/models.py:257
|
||||
msgid ""
|
||||
"Invalid format. Please check the requirements for your selected interval "
|
||||
"type."
|
||||
@@ -2654,15 +2654,21 @@ msgstr "目標"
|
||||
msgid "Last fetch"
|
||||
msgstr "最後更新"
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:61
|
||||
#: templates/exchange_rates_services/fragments/list.html:62
|
||||
#, python-format
|
||||
msgid "%(counter)s consecutive failure"
|
||||
msgid_plural "%(counter)s consecutive failures"
|
||||
msgstr[0] ""
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
msgid "currencies"
|
||||
msgstr "貨幣"
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:61
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
msgid "accounts"
|
||||
msgstr "帳戶"
|
||||
|
||||
#: templates/exchange_rates_services/fragments/list.html:69
|
||||
#: templates/exchange_rates_services/fragments/list.html:77
|
||||
msgid "No services configured"
|
||||
msgstr "沒有設定任何服務"
|
||||
|
||||
|
||||
@@ -56,7 +56,15 @@
|
||||
</td>
|
||||
<td class="table-col-auto">{% if service.is_active %}<i class="fa-solid fa-circle text-success"></i>{% else %}
|
||||
<i class="fa-solid fa-circle text-error"></i>{% endif %}</td>
|
||||
<td class="table-col-auto">{{ service.name }}</td>
|
||||
<td>
|
||||
{{ service.name }}
|
||||
{% if service.failure_count > 0 %}
|
||||
<span class="badge badge-error gap-1" data-tippy-content="{% blocktrans count counter=service.failure_count %}{{ counter }} consecutive failure{% plural %}{{ counter }} consecutive failures{% endblocktrans %}">
|
||||
<i class="fa-solid fa-triangle-exclamation fa-fw"></i>
|
||||
{{ service.failure_count }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ service.get_service_type_display }}</td>
|
||||
<td>{{ service.target_currencies.count }} {% trans 'currencies' %}, {{ service.target_accounts.count }} {% trans 'accounts' %}</td>
|
||||
<td>{{ service.last_fetch|date:"SHORT_DATETIME_FORMAT" }}</td>
|
||||
|
||||
Reference in New Issue
Block a user