Enable filtering and sorting on all API views

This commit is contained in:
icovada
2026-01-08 21:33:33 +00:00
28 changed files with 1967 additions and 244 deletions

View File

@@ -434,14 +434,16 @@ REST_FRAMEWORK = {
"apps.api.permissions.NotInDemoMode", "apps.api.permissions.NotInDemoMode",
"rest_framework.permissions.DjangoModelPermissions", "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': [ 'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.BasicAuthentication', 'rest_framework.authentication.BasicAuthentication',
'rest_framework.authentication.SessionAuthentication', 'rest_framework.authentication.SessionAuthentication',
'rest_framework.authentication.TokenAuthentication', 'rest_framework.authentication.TokenAuthentication',
], ],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", "DEFAULT_PAGINATION_CLASS": "apps.api.custom.pagination.CustomPageNumberPagination",
"PAGE_SIZE": 100,
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
} }

View File

@@ -1,4 +1,5 @@
# Import all test classes for Django test discovery # Import all test classes for Django test discovery
from .test_imports import * from .test_imports import *
from .test_accounts import * from .test_accounts import *
from .test_data_isolation import *
from .test_shared_access import *

View 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()
)

View 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)

View File

@@ -6,7 +6,11 @@ from rest_framework.response import Response
from apps.accounts.models import AccountGroup, Account from apps.accounts.models import AccountGroup, Account
from apps.accounts.services import get_account_balance 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): class AccountGroupViewSet(viewsets.ModelViewSet):
@@ -14,9 +18,16 @@ class AccountGroupViewSet(viewsets.ModelViewSet):
queryset = AccountGroup.objects.all() queryset = AccountGroup.objects.all()
serializer_class = AccountGroupSerializer serializer_class = AccountGroupSerializer
filterset_fields = {
"name": ["exact", "icontains"],
"owner": ["exact"],
}
search_fields = ["name"]
ordering_fields = "__all__"
ordering = ["id"]
def get_queryset(self): def get_queryset(self):
return AccountGroup.objects.all().order_by("id") return AccountGroup.objects.all()
@extend_schema_view( @extend_schema_view(
@@ -31,27 +42,38 @@ class AccountViewSet(viewsets.ModelViewSet):
queryset = Account.objects.all() queryset = Account.objects.all()
serializer_class = AccountSerializer 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): def get_queryset(self):
return ( return Account.objects.all().select_related(
Account.objects.all() "group", "currency", "exchange_currency"
.order_by("id")
.select_related("group", "currency", "exchange_currency")
) )
@action(detail=True, methods=["get"], permission_classes=[IsAuthenticated]) @action(detail=True, methods=["get"], permission_classes=[IsAuthenticated])
def balance(self, request, pk=None): def balance(self, request, pk=None):
"""Get current and projected balance for an account.""" """Get current and projected balance for an account."""
account = self.get_object() account = self.get_object()
current_balance = get_account_balance(account, paid_only=True) current_balance = get_account_balance(account, paid_only=True)
projected_balance = get_account_balance(account, paid_only=False) 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)

View File

@@ -9,8 +9,28 @@ from apps.currencies.models import ExchangeRate
class CurrencyViewSet(viewsets.ModelViewSet): class CurrencyViewSet(viewsets.ModelViewSet):
queryset = Currency.objects.all() queryset = Currency.objects.all()
serializer_class = CurrencySerializer 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): class ExchangeRateViewSet(viewsets.ModelViewSet):
queryset = ExchangeRate.objects.all() queryset = ExchangeRate.objects.all()
serializer_class = ExchangeRateSerializer 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__'

View File

@@ -8,6 +8,19 @@ from apps.api.serializers import DCAStrategySerializer, DCAEntrySerializer
class DCAStrategyViewSet(viewsets.ModelViewSet): class DCAStrategyViewSet(viewsets.ModelViewSet):
queryset = DCAStrategy.objects.all() queryset = DCAStrategy.objects.all()
serializer_class = DCAStrategySerializer 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): def get_queryset(self):
return DCAStrategy.objects.all().order_by("id") return DCAStrategy.objects.all().order_by("id")
@@ -35,10 +48,22 @@ class DCAStrategyViewSet(viewsets.ModelViewSet):
class DCAEntryViewSet(viewsets.ModelViewSet): class DCAEntryViewSet(viewsets.ModelViewSet):
queryset = DCAEntry.objects.all() queryset = DCAEntry.objects.all()
serializer_class = DCAEntrySerializer 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): def get_queryset(self):
queryset = DCAEntry.objects.all() # Filter entries by strategies the user has access to
strategy_id = self.request.query_params.get("strategy", None) accessible_strategies = DCAStrategy.objects.all()
if strategy_id is not None: return DCAEntry.objects.filter(strategy__in=accessible_strategies)
queryset = queryset.filter(strategy_id=strategy_id)
return queryset

View File

@@ -28,6 +28,14 @@ class ImportProfileViewSet(viewsets.ReadOnlyModelViewSet):
queryset = ImportProfile.objects.all() queryset = ImportProfile.objects.all()
serializer_class = ImportProfileSerializer serializer_class = ImportProfileSerializer
permission_classes = [IsAuthenticated] 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( @extend_schema_view(
@@ -55,6 +63,22 @@ class ImportRunViewSet(viewsets.ReadOnlyModelViewSet):
queryset = ImportRun.objects.all().order_by("-id") queryset = ImportRun.objects.all().order_by("-id")
serializer_class = ImportRunSerializer serializer_class = ImportRunSerializer
permission_classes = [IsAuthenticated] 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): def get_queryset(self):
queryset = super().get_queryset() queryset = super().get_queryset()

View File

@@ -24,6 +24,34 @@ from apps.rules.signals import transaction_updated, transaction_created
class TransactionViewSet(viewsets.ModelViewSet): class TransactionViewSet(viewsets.ModelViewSet):
queryset = Transaction.objects.all() queryset = Transaction.objects.all()
serializer_class = TransactionSerializer 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): def perform_create(self, serializer):
instance = serializer.save() instance = serializer.save()
@@ -38,45 +66,109 @@ class TransactionViewSet(viewsets.ModelViewSet):
kwargs["partial"] = True kwargs["partial"] = True
return self.update(request, *args, **kwargs) return self.update(request, *args, **kwargs)
def get_queryset(self):
return Transaction.objects.all().order_by("-id")
class TransactionCategoryViewSet(viewsets.ModelViewSet): class TransactionCategoryViewSet(viewsets.ModelViewSet):
queryset = TransactionCategory.objects.all() queryset = TransactionCategory.objects.all()
serializer_class = TransactionCategorySerializer 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): def get_queryset(self):
return TransactionCategory.objects.all().order_by("id") return TransactionCategory.objects.all()
class TransactionTagViewSet(viewsets.ModelViewSet): class TransactionTagViewSet(viewsets.ModelViewSet):
queryset = TransactionTag.objects.all() queryset = TransactionTag.objects.all()
serializer_class = TransactionTagSerializer serializer_class = TransactionTagSerializer
filterset_fields = {
"name": ["exact", "icontains"],
"active": ["exact"],
"owner": ["exact"],
}
search_fields = ["name"]
ordering_fields = "__all__"
ordering = ["id"]
def get_queryset(self): def get_queryset(self):
return TransactionTag.objects.all().order_by("id") return TransactionTag.objects.all()
class TransactionEntityViewSet(viewsets.ModelViewSet): class TransactionEntityViewSet(viewsets.ModelViewSet):
queryset = TransactionEntity.objects.all() queryset = TransactionEntity.objects.all()
serializer_class = TransactionEntitySerializer serializer_class = TransactionEntitySerializer
filterset_fields = {
"name": ["exact", "icontains"],
"active": ["exact"],
"owner": ["exact"],
}
search_fields = ["name"]
ordering_fields = "__all__"
ordering = ["id"]
def get_queryset(self): def get_queryset(self):
return TransactionEntity.objects.all().order_by("id") return TransactionEntity.objects.all()
class InstallmentPlanViewSet(viewsets.ModelViewSet): class InstallmentPlanViewSet(viewsets.ModelViewSet):
queryset = InstallmentPlan.objects.all() queryset = InstallmentPlan.objects.all()
serializer_class = InstallmentPlanSerializer 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): def get_queryset(self):
return InstallmentPlan.objects.all().order_by("-id") return InstallmentPlan.objects.all()
class RecurringTransactionViewSet(viewsets.ModelViewSet): class RecurringTransactionViewSet(viewsets.ModelViewSet):
queryset = RecurringTransaction.objects.all() queryset = RecurringTransaction.objects.all()
serializer_class = RecurringTransactionSerializer 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): def get_queryset(self):
return RecurringTransaction.objects.all().order_by("-id") return RecurringTransaction.objects.all()

View File

@@ -1,5 +1,4 @@
import logging import logging
from datetime import timedelta
from django.db.models import QuerySet from django.db.models import QuerySet
from django.utils import timezone from django.utils import timezone
@@ -258,7 +257,10 @@ class ExchangeRateFetcher:
processed_pairs.add((from_currency.id, to_currency.id)) processed_pairs.add((from_currency.id, to_currency.id))
service.last_fetch = timezone.now() service.last_fetch = timezone.now()
service.failure_count = 0
service.save() service.save()
except Exception as e: except Exception as e:
logger.error(f"Error fetching rates for {service.name}: {e}") logger.error(f"Error fetching rates for {service.name}: {e}")
service.failure_count += 1
service.save()

View 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),
),
]

View File

@@ -136,6 +136,8 @@ class ExchangeRateService(models.Model):
null=True, blank=True, verbose_name=_("Last Successful Fetch") null=True, blank=True, verbose_name=_("Last Successful Fetch")
) )
failure_count = models.PositiveIntegerField(default=0)
target_currencies = models.ManyToManyField( target_currencies = models.ManyToManyField(
Currency, Currency,
verbose_name=_("Target Currencies"), verbose_name=_("Target Currencies"),
@@ -237,7 +239,7 @@ class ExchangeRateService(models.Model):
hours = self._parse_hour_ranges(self.fetch_interval) hours = self._parse_hour_ranges(self.fetch_interval)
# Store in normalized format (optional) # Store in normalized format (optional)
self.fetch_interval = ",".join(str(h) for h in sorted(hours)) self.fetch_interval = ",".join(str(h) for h in sorted(hours))
except ValueError as e: except ValueError:
raise ValidationError( raise ValidationError(
{ {
"fetch_interval": _( "fetch_interval": _(
@@ -248,7 +250,7 @@ class ExchangeRateService(models.Model):
) )
except ValidationError: except ValidationError:
raise raise
except Exception as e: except Exception:
raise ValidationError( raise ValidationError(
{ {
"fetch_interval": _( "fetch_interval": _(

View File

@@ -0,0 +1 @@
# Tests package for currencies app

View 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)

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: \n" "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \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" "PO-Revision-Date: 2025-11-01 01:17+0000\n"
"Last-Translator: mlystopad <mlystopadt@gmail.com>\n" "Last-Translator: mlystopad <mlystopadt@gmail.com>\n"
"Language-Team: German <https://translations.herculino.com/projects/wygiwyh/" "Language-Team: German <https://translations.herculino.com/projects/wygiwyh/"
@@ -608,11 +608,11 @@ msgstr "Intervall"
msgid "Last Successful Fetch" msgid "Last Successful Fetch"
msgstr "Letzter erfolgreicher Abruf" msgstr "Letzter erfolgreicher Abruf"
#: apps/currencies/models.py:141 #: apps/currencies/models.py:143
msgid "Target Currencies" msgid "Target Currencies"
msgstr "Zielwährungen" msgstr "Zielwährungen"
#: apps/currencies/models.py:143 #: apps/currencies/models.py:145
msgid "" msgid ""
"Select currencies to fetch exchange rates for. Rates will be fetched for " "Select currencies to fetch exchange rates for. Rates will be fetched for "
"each currency against their set exchange currency." "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 auswählen, dessen Umrechnungskurs abgerufen werden sollen. Für jede "
"Währung wird der Kurs der entsprechenden Umrechnungs-Währung abgerufen." "Währung wird der Kurs der entsprechenden Umrechnungs-Währung abgerufen."
#: apps/currencies/models.py:151 #: apps/currencies/models.py:153
msgid "Target Accounts" msgid "Target Accounts"
msgstr "Zielkonten" msgstr "Zielkonten"
#: apps/currencies/models.py:153 #: apps/currencies/models.py:155
msgid "" msgid ""
"Select accounts to fetch exchange rates for. Rates will be fetched for each " "Select accounts to fetch exchange rates for. Rates will be fetched for each "
"account's currency against their set exchange currency." "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 " "Konten auswählen, für die Umrechungskurse abgerufen werden solen. Für jedes "
"Konto wird der Kurs der entsprechenden Umrechnungs-Währung abgerufen." "Konto wird der Kurs der entsprechenden Umrechnungs-Währung abgerufen."
#: apps/currencies/models.py:160 #: apps/currencies/models.py:162
#, fuzzy #, fuzzy
#| msgid "Edit exchange rate" #| msgid "Edit exchange rate"
msgid "Single exchange rate" msgid "Single exchange rate"
msgstr "Umrechnungskurs bearbeiten" 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." msgid "Create one exchange rate and keep updating it. Avoids database clutter."
msgstr "" msgstr ""
#: apps/currencies/models.py:168 #: apps/currencies/models.py:170
msgid "Exchange Rate Service" msgid "Exchange Rate Service"
msgstr "Umrechnungskurs-Dienst" msgstr "Umrechnungskurs-Dienst"
#: apps/currencies/models.py:169 #: apps/currencies/models.py:171
msgid "Exchange Rate Services" msgid "Exchange Rate Services"
msgstr "Umrechnungskurs-Dienste" msgstr "Umrechnungskurs-Dienste"
#: apps/currencies/models.py:221 #: apps/currencies/models.py:223
msgid "'Every X hours' interval type requires a positive integer." msgid "'Every X hours' interval type requires a positive integer."
msgstr "\"Jede X Stunden\"-Intervalltyp benötigt eine positive Ganzzahl." 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." msgid "'Every X hours' interval must be between 1 and 24."
msgstr "\"Jede X Stunden\"-Intervall muss zwischen 1 und 24 liegen." msgstr "\"Jede X Stunden\"-Intervall muss zwischen 1 und 24 liegen."
#: apps/currencies/models.py:244 #: apps/currencies/models.py:246
msgid "" msgid ""
"Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., " "Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., "
"'1-5,8,10-12')." "'1-5,8,10-12')."
@@ -666,7 +666,7 @@ msgstr ""
"Ungültiges Stundenformat. Nutze kommagetrennte Stunden (0-23) und/oder " "Ungültiges Stundenformat. Nutze kommagetrennte Stunden (0-23) und/oder "
"Zeiträume (z.B. \"1-5,8,10-12\")." "Zeiträume (z.B. \"1-5,8,10-12\")."
#: apps/currencies/models.py:255 #: apps/currencies/models.py:257
msgid "" msgid ""
"Invalid format. Please check the requirements for your selected interval " "Invalid format. Please check the requirements for your selected interval "
"type." "type."
@@ -2727,15 +2727,22 @@ msgstr "Ziel"
msgid "Last fetch" msgid "Last fetch"
msgstr "Letzter Abruf" 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" msgid "currencies"
msgstr "Währungen" msgstr "Währungen"
#: templates/exchange_rates_services/fragments/list.html:61 #: templates/exchange_rates_services/fragments/list.html:69
msgid "accounts" msgid "accounts"
msgstr "Konten" msgstr "Konten"
#: templates/exchange_rates_services/fragments/list.html:69 #: templates/exchange_rates_services/fragments/list.html:77
msgid "No services configured" msgid "No services configured"
msgstr "Keine Dienste konfiguriert" msgstr "Keine Dienste konfiguriert"

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \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" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -596,57 +596,57 @@ msgstr ""
msgid "Last Successful Fetch" msgid "Last Successful Fetch"
msgstr "" msgstr ""
#: apps/currencies/models.py:141 #: apps/currencies/models.py:143
msgid "Target Currencies" msgid "Target Currencies"
msgstr "" msgstr ""
#: apps/currencies/models.py:143 #: apps/currencies/models.py:145
msgid "" msgid ""
"Select currencies to fetch exchange rates for. Rates will be fetched for " "Select currencies to fetch exchange rates for. Rates will be fetched for "
"each currency against their set exchange currency." "each currency against their set exchange currency."
msgstr "" msgstr ""
#: apps/currencies/models.py:151 #: apps/currencies/models.py:153
msgid "Target Accounts" msgid "Target Accounts"
msgstr "" msgstr ""
#: apps/currencies/models.py:153 #: apps/currencies/models.py:155
msgid "" msgid ""
"Select accounts to fetch exchange rates for. Rates will be fetched for each " "Select accounts to fetch exchange rates for. Rates will be fetched for each "
"account's currency against their set exchange currency." "account's currency against their set exchange currency."
msgstr "" msgstr ""
#: apps/currencies/models.py:160 #: apps/currencies/models.py:162
msgid "Single exchange rate" msgid "Single exchange rate"
msgstr "" msgstr ""
#: apps/currencies/models.py:163 #: apps/currencies/models.py:165
msgid "Create one exchange rate and keep updating it. Avoids database clutter." msgid "Create one exchange rate and keep updating it. Avoids database clutter."
msgstr "" msgstr ""
#: apps/currencies/models.py:168 #: apps/currencies/models.py:170
msgid "Exchange Rate Service" msgid "Exchange Rate Service"
msgstr "" msgstr ""
#: apps/currencies/models.py:169 #: apps/currencies/models.py:171
msgid "Exchange Rate Services" msgid "Exchange Rate Services"
msgstr "" msgstr ""
#: apps/currencies/models.py:221 #: apps/currencies/models.py:223
msgid "'Every X hours' interval type requires a positive integer." msgid "'Every X hours' interval type requires a positive integer."
msgstr "" msgstr ""
#: apps/currencies/models.py:230 #: apps/currencies/models.py:232
msgid "'Every X hours' interval must be between 1 and 24." msgid "'Every X hours' interval must be between 1 and 24."
msgstr "" msgstr ""
#: apps/currencies/models.py:244 #: apps/currencies/models.py:246
msgid "" msgid ""
"Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., " "Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., "
"'1-5,8,10-12')." "'1-5,8,10-12')."
msgstr "" msgstr ""
#: apps/currencies/models.py:255 #: apps/currencies/models.py:257
msgid "" msgid ""
"Invalid format. Please check the requirements for your selected interval " "Invalid format. Please check the requirements for your selected interval "
"type." "type."
@@ -2649,15 +2649,22 @@ msgstr ""
msgid "Last fetch" msgid "Last fetch"
msgstr "" 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" msgid "currencies"
msgstr "" msgstr ""
#: templates/exchange_rates_services/fragments/list.html:61 #: templates/exchange_rates_services/fragments/list.html:69
msgid "accounts" msgid "accounts"
msgstr "" msgstr ""
#: templates/exchange_rates_services/fragments/list.html:69 #: templates/exchange_rates_services/fragments/list.html:77
msgid "No services configured" msgid "No services configured"
msgstr "" msgstr ""

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \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" "PO-Revision-Date: 2025-12-16 05:24+0000\n"
"Last-Translator: BRodolfo <simplysmartbydesign@gmail.com>\n" "Last-Translator: BRodolfo <simplysmartbydesign@gmail.com>\n"
"Language-Team: Spanish <https://translations.herculino.com/projects/wygiwyh/" "Language-Team: Spanish <https://translations.herculino.com/projects/wygiwyh/"
@@ -605,11 +605,11 @@ msgstr "Intervalo"
msgid "Last Successful Fetch" msgid "Last Successful Fetch"
msgstr "Última Sincronización Exitosa" msgstr "Última Sincronización Exitosa"
#: apps/currencies/models.py:141 #: apps/currencies/models.py:143
msgid "Target Currencies" msgid "Target Currencies"
msgstr "Monedas de Destino" msgstr "Monedas de Destino"
#: apps/currencies/models.py:143 #: apps/currencies/models.py:145
msgid "" msgid ""
"Select currencies to fetch exchange rates for. Rates will be fetched for " "Select currencies to fetch exchange rates for. Rates will be fetched for "
"each currency against their set exchange currency." "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 " "tasas se consultarán para cada moneda en relación con su moneda de "
"referencia establecida." "referencia establecida."
#: apps/currencies/models.py:151 #: apps/currencies/models.py:153
msgid "Target Accounts" msgid "Target Accounts"
msgstr "Cuentas de Destino" msgstr "Cuentas de Destino"
#: apps/currencies/models.py:153 #: apps/currencies/models.py:155
msgid "" msgid ""
"Select accounts to fetch exchange rates for. Rates will be fetched for each " "Select accounts to fetch exchange rates for. Rates will be fetched for each "
"account's currency against their set exchange currency." "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 " "tasas se consultarán para la moneda de cada cuenta en relación con su moneda "
"de referencia establecida." "de referencia establecida."
#: apps/currencies/models.py:160 #: apps/currencies/models.py:162
msgid "Single exchange rate" msgid "Single exchange rate"
msgstr "Tasa de cambio única" 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." msgid "Create one exchange rate and keep updating it. Avoids database clutter."
msgstr "" msgstr ""
"Crea una única tasa de cambio y mantenla actualizada. Evita la acumulación " "Crea una única tasa de cambio y mantenla actualizada. Evita la acumulación "
"de datos en la base de datos." "de datos en la base de datos."
#: apps/currencies/models.py:168 #: apps/currencies/models.py:170
msgid "Exchange Rate Service" msgid "Exchange Rate Service"
msgstr "Servicio de Tasas de Cambio" msgstr "Servicio de Tasas de Cambio"
#: apps/currencies/models.py:169 #: apps/currencies/models.py:171
msgid "Exchange Rate Services" msgid "Exchange Rate Services"
msgstr "Servicios de Tasas de Cambio" 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." msgid "'Every X hours' interval type requires a positive integer."
msgstr "El tipo de intervalo 'Cada X horas' requiere un entero positivo." 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." 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." 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 "" msgid ""
"Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., " "Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., "
"'1-5,8,10-12')." "'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 " "Formato de hora no válido. Usa horas separadas por coma (0-23) y/o rangos "
"(p. ej., \"1-5,8,10-12\")." "(p. ej., \"1-5,8,10-12\")."
#: apps/currencies/models.py:255 #: apps/currencies/models.py:257
msgid "" msgid ""
"Invalid format. Please check the requirements for your selected interval " "Invalid format. Please check the requirements for your selected interval "
"type." "type."
@@ -2692,15 +2692,22 @@ msgstr "Dirigido a"
msgid "Last fetch" msgid "Last fetch"
msgstr "Última sincronización" 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" msgid "currencies"
msgstr "monedas" msgstr "monedas"
#: templates/exchange_rates_services/fragments/list.html:61 #: templates/exchange_rates_services/fragments/list.html:69
msgid "accounts" msgid "accounts"
msgstr "cuentas" msgstr "cuentas"
#: templates/exchange_rates_services/fragments/list.html:69 #: templates/exchange_rates_services/fragments/list.html:77
msgid "No services configured" msgid "No services configured"
msgstr "No hay servicios configurados" msgstr "No hay servicios configurados"

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \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" "PO-Revision-Date: 2025-10-07 20:17+0000\n"
"Last-Translator: Erwan Colin <zephone@protonmail.com>\n" "Last-Translator: Erwan Colin <zephone@protonmail.com>\n"
"Language-Team: French <https://translations.herculino.com/projects/wygiwyh/" "Language-Team: French <https://translations.herculino.com/projects/wygiwyh/"
@@ -607,11 +607,11 @@ msgstr "Intervalle"
msgid "Last Successful Fetch" msgid "Last Successful Fetch"
msgstr "Dernière récupération avec succès" msgstr "Dernière récupération avec succès"
#: apps/currencies/models.py:141 #: apps/currencies/models.py:143
msgid "Target Currencies" msgid "Target Currencies"
msgstr "Devises cibles" msgstr "Devises cibles"
#: apps/currencies/models.py:143 #: apps/currencies/models.py:145
msgid "" msgid ""
"Select currencies to fetch exchange rates for. Rates will be fetched for " "Select currencies to fetch exchange rates for. Rates will be fetched for "
"each currency against their set exchange currency." "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 " "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." "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" msgid "Target Accounts"
msgstr "Comptes cibles" msgstr "Comptes cibles"
#: apps/currencies/models.py:153 #: apps/currencies/models.py:155
msgid "" msgid ""
"Select accounts to fetch exchange rates for. Rates will be fetched for each " "Select accounts to fetch exchange rates for. Rates will be fetched for each "
"account's currency against their set exchange currency." "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 " "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." "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" msgid "Single exchange rate"
msgstr "Taux de change unique" 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." msgid "Create one exchange rate and keep updating it. Avoids database clutter."
msgstr "" msgstr ""
"Ne créer qu'un seul taux de change et le mettre à jour. Evite d'engorger la " "Ne créer qu'un seul taux de change et le mettre à jour. Evite d'engorger la "
"base de donnée." "base de donnée."
#: apps/currencies/models.py:168 #: apps/currencies/models.py:170
msgid "Exchange Rate Service" msgid "Exchange Rate Service"
msgstr "Service de taux de change" msgstr "Service de taux de change"
#: apps/currencies/models.py:169 #: apps/currencies/models.py:171
msgid "Exchange Rate Services" msgid "Exchange Rate Services"
msgstr "Services de taux de change" 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." msgid "'Every X hours' interval type requires a positive integer."
msgstr "'Toutes les X heures' l'intervalle requiert un entier positif." 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." 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." 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 "" msgid ""
"Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., " "Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., "
"'1-5,8,10-12')." "'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 " "Format d'heure invalide. Utilisez les heures séparé par virgule (0-23) et/ou "
"une plage (ex : '1-5,8,10-12')." "une plage (ex : '1-5,8,10-12')."
#: apps/currencies/models.py:255 #: apps/currencies/models.py:257
msgid "" msgid ""
"Invalid format. Please check the requirements for your selected interval " "Invalid format. Please check the requirements for your selected interval "
"type." "type."
@@ -2707,15 +2707,22 @@ msgstr "Ciblage"
msgid "Last fetch" msgid "Last fetch"
msgstr "Dernière récupération" 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" msgid "currencies"
msgstr "devises" msgstr "devises"
#: templates/exchange_rates_services/fragments/list.html:61 #: templates/exchange_rates_services/fragments/list.html:69
msgid "accounts" msgid "accounts"
msgstr "Comptes" msgstr "Comptes"
#: templates/exchange_rates_services/fragments/list.html:69 #: templates/exchange_rates_services/fragments/list.html:77
msgid "No services configured" msgid "No services configured"
msgstr "Pas de services configurés" msgstr "Pas de services configurés"

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \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" "PO-Revision-Date: 2026-01-10 03:09+0000\n"
"Last-Translator: Herculino Trotta <netotrotta@gmail.com>\n" "Last-Translator: Herculino Trotta <netotrotta@gmail.com>\n"
"Language-Team: Hungarian <https://translations.herculino.com/projects/" "Language-Team: Hungarian <https://translations.herculino.com/projects/"
@@ -602,57 +602,57 @@ msgstr "Intervallum"
msgid "Last Successful Fetch" msgid "Last Successful Fetch"
msgstr "Utolsó sikeres elérés" msgstr "Utolsó sikeres elérés"
#: apps/currencies/models.py:141 #: apps/currencies/models.py:143
msgid "Target Currencies" msgid "Target Currencies"
msgstr "Cél pénznemek" msgstr "Cél pénznemek"
#: apps/currencies/models.py:143 #: apps/currencies/models.py:145
msgid "" msgid ""
"Select currencies to fetch exchange rates for. Rates will be fetched for " "Select currencies to fetch exchange rates for. Rates will be fetched for "
"each currency against their set exchange currency." "each currency against their set exchange currency."
msgstr "" msgstr ""
#: apps/currencies/models.py:151 #: apps/currencies/models.py:153
msgid "Target Accounts" msgid "Target Accounts"
msgstr "Cél számlák" msgstr "Cél számlák"
#: apps/currencies/models.py:153 #: apps/currencies/models.py:155
msgid "" msgid ""
"Select accounts to fetch exchange rates for. Rates will be fetched for each " "Select accounts to fetch exchange rates for. Rates will be fetched for each "
"account's currency against their set exchange currency." "account's currency against their set exchange currency."
msgstr "" msgstr ""
#: apps/currencies/models.py:160 #: apps/currencies/models.py:162
msgid "Single exchange rate" msgid "Single exchange rate"
msgstr "" msgstr ""
#: apps/currencies/models.py:163 #: apps/currencies/models.py:165
msgid "Create one exchange rate and keep updating it. Avoids database clutter." msgid "Create one exchange rate and keep updating it. Avoids database clutter."
msgstr "" msgstr ""
#: apps/currencies/models.py:168 #: apps/currencies/models.py:170
msgid "Exchange Rate Service" msgid "Exchange Rate Service"
msgstr "" msgstr ""
#: apps/currencies/models.py:169 #: apps/currencies/models.py:171
msgid "Exchange Rate Services" msgid "Exchange Rate Services"
msgstr "" msgstr ""
#: apps/currencies/models.py:221 #: apps/currencies/models.py:223
msgid "'Every X hours' interval type requires a positive integer." msgid "'Every X hours' interval type requires a positive integer."
msgstr "" msgstr ""
#: apps/currencies/models.py:230 #: apps/currencies/models.py:232
msgid "'Every X hours' interval must be between 1 and 24." msgid "'Every X hours' interval must be between 1 and 24."
msgstr "" msgstr ""
#: apps/currencies/models.py:244 #: apps/currencies/models.py:246
msgid "" msgid ""
"Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., " "Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., "
"'1-5,8,10-12')." "'1-5,8,10-12')."
msgstr "" msgstr ""
#: apps/currencies/models.py:255 #: apps/currencies/models.py:257
msgid "" msgid ""
"Invalid format. Please check the requirements for your selected interval " "Invalid format. Please check the requirements for your selected interval "
"type." "type."
@@ -2655,15 +2655,22 @@ msgstr ""
msgid "Last fetch" msgid "Last fetch"
msgstr "" 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" msgid "currencies"
msgstr "" msgstr ""
#: templates/exchange_rates_services/fragments/list.html:61 #: templates/exchange_rates_services/fragments/list.html:69
msgid "accounts" msgid "accounts"
msgstr "" msgstr ""
#: templates/exchange_rates_services/fragments/list.html:69 #: templates/exchange_rates_services/fragments/list.html:77
msgid "No services configured" msgid "No services configured"
msgstr "" msgstr ""

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \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" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: Automatically generated\n" "Last-Translator: Automatically generated\n"
"Language-Team: none\n" "Language-Team: none\n"
@@ -595,57 +595,57 @@ msgstr ""
msgid "Last Successful Fetch" msgid "Last Successful Fetch"
msgstr "" msgstr ""
#: apps/currencies/models.py:141 #: apps/currencies/models.py:143
msgid "Target Currencies" msgid "Target Currencies"
msgstr "" msgstr ""
#: apps/currencies/models.py:143 #: apps/currencies/models.py:145
msgid "" msgid ""
"Select currencies to fetch exchange rates for. Rates will be fetched for " "Select currencies to fetch exchange rates for. Rates will be fetched for "
"each currency against their set exchange currency." "each currency against their set exchange currency."
msgstr "" msgstr ""
#: apps/currencies/models.py:151 #: apps/currencies/models.py:153
msgid "Target Accounts" msgid "Target Accounts"
msgstr "" msgstr ""
#: apps/currencies/models.py:153 #: apps/currencies/models.py:155
msgid "" msgid ""
"Select accounts to fetch exchange rates for. Rates will be fetched for each " "Select accounts to fetch exchange rates for. Rates will be fetched for each "
"account's currency against their set exchange currency." "account's currency against their set exchange currency."
msgstr "" msgstr ""
#: apps/currencies/models.py:160 #: apps/currencies/models.py:162
msgid "Single exchange rate" msgid "Single exchange rate"
msgstr "" msgstr ""
#: apps/currencies/models.py:163 #: apps/currencies/models.py:165
msgid "Create one exchange rate and keep updating it. Avoids database clutter." msgid "Create one exchange rate and keep updating it. Avoids database clutter."
msgstr "" msgstr ""
#: apps/currencies/models.py:168 #: apps/currencies/models.py:170
msgid "Exchange Rate Service" msgid "Exchange Rate Service"
msgstr "" msgstr ""
#: apps/currencies/models.py:169 #: apps/currencies/models.py:171
msgid "Exchange Rate Services" msgid "Exchange Rate Services"
msgstr "" msgstr ""
#: apps/currencies/models.py:221 #: apps/currencies/models.py:223
msgid "'Every X hours' interval type requires a positive integer." msgid "'Every X hours' interval type requires a positive integer."
msgstr "" msgstr ""
#: apps/currencies/models.py:230 #: apps/currencies/models.py:232
msgid "'Every X hours' interval must be between 1 and 24." msgid "'Every X hours' interval must be between 1 and 24."
msgstr "" msgstr ""
#: apps/currencies/models.py:244 #: apps/currencies/models.py:246
msgid "" msgid ""
"Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., " "Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., "
"'1-5,8,10-12')." "'1-5,8,10-12')."
msgstr "" msgstr ""
#: apps/currencies/models.py:255 #: apps/currencies/models.py:257
msgid "" msgid ""
"Invalid format. Please check the requirements for your selected interval " "Invalid format. Please check the requirements for your selected interval "
"type." "type."
@@ -2648,15 +2648,21 @@ msgstr ""
msgid "Last fetch" msgid "Last fetch"
msgstr "" 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" msgid "currencies"
msgstr "" msgstr ""
#: templates/exchange_rates_services/fragments/list.html:61 #: templates/exchange_rates_services/fragments/list.html:69
msgid "accounts" msgid "accounts"
msgstr "" msgstr ""
#: templates/exchange_rates_services/fragments/list.html:69 #: templates/exchange_rates_services/fragments/list.html:77
msgid "No services configured" msgid "No services configured"
msgstr "" msgstr ""

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \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" "PO-Revision-Date: 2025-12-28 22:24+0000\n"
"Last-Translator: icovada <federico.tabbo@networktocode.com>\n" "Last-Translator: icovada <federico.tabbo@networktocode.com>\n"
"Language-Team: Italian <https://translations.herculino.com/projects/wygiwyh/" "Language-Team: Italian <https://translations.herculino.com/projects/wygiwyh/"
@@ -607,11 +607,11 @@ msgstr "Intervallo"
msgid "Last Successful Fetch" msgid "Last Successful Fetch"
msgstr "Ultimo scaricamento riuscito" msgstr "Ultimo scaricamento riuscito"
#: apps/currencies/models.py:141 #: apps/currencies/models.py:143
msgid "Target Currencies" msgid "Target Currencies"
msgstr "Valute target" msgstr "Valute target"
#: apps/currencies/models.py:143 #: apps/currencies/models.py:145
msgid "" msgid ""
"Select currencies to fetch exchange rates for. Rates will be fetched for " "Select currencies to fetch exchange rates for. Rates will be fetched for "
"each currency against their set exchange currency." "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 " "Seleziona le valute per cui scaricare i tassi di cambio. I tassi verranno "
"scaricati per ogni valuta rispetto alla valuta di cambio impostata." "scaricati per ogni valuta rispetto alla valuta di cambio impostata."
#: apps/currencies/models.py:151 #: apps/currencies/models.py:153
msgid "Target Accounts" msgid "Target Accounts"
msgstr "Conti target" msgstr "Conti target"
#: apps/currencies/models.py:153 #: apps/currencies/models.py:155
msgid "" msgid ""
"Select accounts to fetch exchange rates for. Rates will be fetched for each " "Select accounts to fetch exchange rates for. Rates will be fetched for each "
"account's currency against their set exchange currency." "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 " "scaricati per la valuta di ogni conto rispetto alla valuta di cambio "
"impostata." "impostata."
#: apps/currencies/models.py:160 #: apps/currencies/models.py:162
msgid "Single exchange rate" msgid "Single exchange rate"
msgstr "Cambio unico" 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." msgid "Create one exchange rate and keep updating it. Avoids database clutter."
msgstr "" msgstr ""
"Crea un solo cambio valuta e aggiornalo nel tempo, evitando duplicati nel " "Crea un solo cambio valuta e aggiornalo nel tempo, evitando duplicati nel "
"database." "database."
#: apps/currencies/models.py:168 #: apps/currencies/models.py:170
msgid "Exchange Rate Service" msgid "Exchange Rate Service"
msgstr "Servizio cambio valuta" msgstr "Servizio cambio valuta"
#: apps/currencies/models.py:169 #: apps/currencies/models.py:171
msgid "Exchange Rate Services" msgid "Exchange Rate Services"
msgstr "Servizi tassi di cambio" 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." msgid "'Every X hours' interval type requires a positive integer."
msgstr "Lintervallo “Ogni X ore” richiede un numero intero positivo." msgstr "Lintervallo “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." msgid "'Every X hours' interval must be between 1 and 24."
msgstr "Lintervallo “Ogni X ore” deve essere compreso tra 1 e 24." msgstr "Lintervallo “Ogni X ore” deve essere compreso tra 1 e 24."
#: apps/currencies/models.py:244 #: apps/currencies/models.py:246
msgid "" msgid ""
"Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., " "Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., "
"'1-5,8,10-12')." "'1-5,8,10-12')."
@@ -666,7 +666,7 @@ msgstr ""
"Formato ore non valido. Usa ore separate da virgole (023) e/o intervalli " "Formato ore non valido. Usa ore separate da virgole (023) e/o intervalli "
"(es. '1-5,8,10-12')." "(es. '1-5,8,10-12')."
#: apps/currencies/models.py:255 #: apps/currencies/models.py:257
msgid "" msgid ""
"Invalid format. Please check the requirements for your selected interval " "Invalid format. Please check the requirements for your selected interval "
"type." "type."
@@ -2698,15 +2698,22 @@ msgstr "Targeting"
msgid "Last fetch" msgid "Last fetch"
msgstr "Ultimo recupero" 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" msgid "currencies"
msgstr "valute" msgstr "valute"
#: templates/exchange_rates_services/fragments/list.html:61 #: templates/exchange_rates_services/fragments/list.html:69
msgid "accounts" msgid "accounts"
msgstr "conti" msgstr "conti"
#: templates/exchange_rates_services/fragments/list.html:69 #: templates/exchange_rates_services/fragments/list.html:77
msgid "No services configured" msgid "No services configured"
msgstr "Nessun servizio configurato" msgstr "Nessun servizio configurato"

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: \n" "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \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" "PO-Revision-Date: 2025-12-29 07:24+0000\n"
"Last-Translator: Dimitri Decrock <dj.flashpower@gmail.com>\n" "Last-Translator: Dimitri Decrock <dj.flashpower@gmail.com>\n"
"Language-Team: Dutch <https://translations.herculino.com/projects/wygiwyh/" "Language-Team: Dutch <https://translations.herculino.com/projects/wygiwyh/"
@@ -607,11 +607,11 @@ msgstr "Interval"
msgid "Last Successful Fetch" msgid "Last Successful Fetch"
msgstr "Laatste Succesvolle Ophaling" msgstr "Laatste Succesvolle Ophaling"
#: apps/currencies/models.py:141 #: apps/currencies/models.py:143
msgid "Target Currencies" msgid "Target Currencies"
msgstr "Doel Munteenheden" msgstr "Doel Munteenheden"
#: apps/currencies/models.py:143 #: apps/currencies/models.py:145
msgid "" msgid ""
"Select currencies to fetch exchange rates for. Rates will be fetched for " "Select currencies to fetch exchange rates for. Rates will be fetched for "
"each currency against their set exchange currency." "each currency against their set exchange currency."
@@ -619,11 +619,11 @@ msgstr ""
"Selecteer munteenheden om wisselkoersen voor op te halen. De koersen worden " "Selecteer munteenheden om wisselkoersen voor op te halen. De koersen worden "
"voor elke munteenheid opgehaald ten opzichte van de ingestelde wisselkoers." "voor elke munteenheid opgehaald ten opzichte van de ingestelde wisselkoers."
#: apps/currencies/models.py:151 #: apps/currencies/models.py:153
msgid "Target Accounts" msgid "Target Accounts"
msgstr "Naar rekeningen" msgstr "Naar rekeningen"
#: apps/currencies/models.py:153 #: apps/currencies/models.py:155
msgid "" msgid ""
"Select accounts to fetch exchange rates for. Rates will be fetched for each " "Select accounts to fetch exchange rates for. Rates will be fetched for each "
"account's currency against their set exchange currency." "account's currency against their set exchange currency."
@@ -632,33 +632,33 @@ msgstr ""
"opgehaald voor de munteenheid van elke rekening ten opzichte van de " "opgehaald voor de munteenheid van elke rekening ten opzichte van de "
"ingestelde wisselkoers." "ingestelde wisselkoers."
#: apps/currencies/models.py:160 #: apps/currencies/models.py:162
msgid "Single exchange rate" msgid "Single exchange rate"
msgstr "Enkele Wisselkoers" 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." msgid "Create one exchange rate and keep updating it. Avoids database clutter."
msgstr "" msgstr ""
"Maak één wisselkoers aan en houd deze bijgewerkt. Voorkomt een overvolle " "Maak één wisselkoers aan en houd deze bijgewerkt. Voorkomt een overvolle "
"database." "database."
#: apps/currencies/models.py:168 #: apps/currencies/models.py:170
msgid "Exchange Rate Service" msgid "Exchange Rate Service"
msgstr "Wisselkoersdienst" msgstr "Wisselkoersdienst"
#: apps/currencies/models.py:169 #: apps/currencies/models.py:171
msgid "Exchange Rate Services" msgid "Exchange Rate Services"
msgstr "Wisselkoersdiensten" msgstr "Wisselkoersdiensten"
#: apps/currencies/models.py:221 #: apps/currencies/models.py:223
msgid "'Every X hours' interval type requires a positive integer." msgid "'Every X hours' interval type requires a positive integer."
msgstr "Voor het intervaltype Elke X uur is een positief geheel getal nodig." 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." msgid "'Every X hours' interval must be between 1 and 24."
msgstr "Het interval Elke X uur moet tussen 1 en 24 liggen." msgstr "Het interval Elke X uur moet tussen 1 en 24 liggen."
#: apps/currencies/models.py:244 #: apps/currencies/models.py:246
msgid "" msgid ""
"Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., " "Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., "
"'1-5,8,10-12')." "'1-5,8,10-12')."
@@ -666,7 +666,7 @@ msgstr ""
"Ongeldige urennotatie. Gebruik door komma's gescheiden uren (0-23) en/of " "Ongeldige urennotatie. Gebruik door komma's gescheiden uren (0-23) en/of "
"reeksen (bijv. 1-5,8,10-12)." "reeksen (bijv. 1-5,8,10-12)."
#: apps/currencies/models.py:255 #: apps/currencies/models.py:257
msgid "" msgid ""
"Invalid format. Please check the requirements for your selected interval " "Invalid format. Please check the requirements for your selected interval "
"type." "type."
@@ -2689,15 +2689,22 @@ msgstr "Gericht op"
msgid "Last fetch" msgid "Last fetch"
msgstr "Laatst opgehaald" 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" msgid "currencies"
msgstr "munteenheden" msgstr "munteenheden"
#: templates/exchange_rates_services/fragments/list.html:61 #: templates/exchange_rates_services/fragments/list.html:69
msgid "accounts" msgid "accounts"
msgstr "rekeningen" msgstr "rekeningen"
#: templates/exchange_rates_services/fragments/list.html:69 #: templates/exchange_rates_services/fragments/list.html:77
msgid "No services configured" msgid "No services configured"
msgstr "Geen diensten ingesteld" msgstr "Geen diensten ingesteld"

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \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" "PO-Revision-Date: 2025-11-08 12:20+0000\n"
"Last-Translator: Marcin Kisielewski <kisielewski.mar@gmail.com>\n" "Last-Translator: Marcin Kisielewski <kisielewski.mar@gmail.com>\n"
"Language-Team: Polish <https://translations.herculino.com/projects/wygiwyh/" "Language-Team: Polish <https://translations.herculino.com/projects/wygiwyh/"
@@ -598,57 +598,57 @@ msgstr ""
msgid "Last Successful Fetch" msgid "Last Successful Fetch"
msgstr "" msgstr ""
#: apps/currencies/models.py:141 #: apps/currencies/models.py:143
msgid "Target Currencies" msgid "Target Currencies"
msgstr "" msgstr ""
#: apps/currencies/models.py:143 #: apps/currencies/models.py:145
msgid "" msgid ""
"Select currencies to fetch exchange rates for. Rates will be fetched for " "Select currencies to fetch exchange rates for. Rates will be fetched for "
"each currency against their set exchange currency." "each currency against their set exchange currency."
msgstr "" msgstr ""
#: apps/currencies/models.py:151 #: apps/currencies/models.py:153
msgid "Target Accounts" msgid "Target Accounts"
msgstr "" msgstr ""
#: apps/currencies/models.py:153 #: apps/currencies/models.py:155
msgid "" msgid ""
"Select accounts to fetch exchange rates for. Rates will be fetched for each " "Select accounts to fetch exchange rates for. Rates will be fetched for each "
"account's currency against their set exchange currency." "account's currency against their set exchange currency."
msgstr "" msgstr ""
#: apps/currencies/models.py:160 #: apps/currencies/models.py:162
msgid "Single exchange rate" msgid "Single exchange rate"
msgstr "" msgstr ""
#: apps/currencies/models.py:163 #: apps/currencies/models.py:165
msgid "Create one exchange rate and keep updating it. Avoids database clutter." msgid "Create one exchange rate and keep updating it. Avoids database clutter."
msgstr "" msgstr ""
#: apps/currencies/models.py:168 #: apps/currencies/models.py:170
msgid "Exchange Rate Service" msgid "Exchange Rate Service"
msgstr "" msgstr ""
#: apps/currencies/models.py:169 #: apps/currencies/models.py:171
msgid "Exchange Rate Services" msgid "Exchange Rate Services"
msgstr "" msgstr ""
#: apps/currencies/models.py:221 #: apps/currencies/models.py:223
msgid "'Every X hours' interval type requires a positive integer." msgid "'Every X hours' interval type requires a positive integer."
msgstr "" msgstr ""
#: apps/currencies/models.py:230 #: apps/currencies/models.py:232
msgid "'Every X hours' interval must be between 1 and 24." msgid "'Every X hours' interval must be between 1 and 24."
msgstr "" msgstr ""
#: apps/currencies/models.py:244 #: apps/currencies/models.py:246
msgid "" msgid ""
"Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., " "Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., "
"'1-5,8,10-12')." "'1-5,8,10-12')."
msgstr "" msgstr ""
#: apps/currencies/models.py:255 #: apps/currencies/models.py:257
msgid "" msgid ""
"Invalid format. Please check the requirements for your selected interval " "Invalid format. Please check the requirements for your selected interval "
"type." "type."
@@ -2651,15 +2651,23 @@ msgstr ""
msgid "Last fetch" msgid "Last fetch"
msgstr "" 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" msgid "currencies"
msgstr "" msgstr ""
#: templates/exchange_rates_services/fragments/list.html:61 #: templates/exchange_rates_services/fragments/list.html:69
msgid "accounts" msgid "accounts"
msgstr "" msgstr ""
#: templates/exchange_rates_services/fragments/list.html:69 #: templates/exchange_rates_services/fragments/list.html:77
msgid "No services configured" msgid "No services configured"
msgstr "" msgstr ""

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: \n" "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \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" "PO-Revision-Date: 2025-12-29 02:24+0000\n"
"Last-Translator: Herculino Trotta <netotrotta@gmail.com>\n" "Last-Translator: Herculino Trotta <netotrotta@gmail.com>\n"
"Language-Team: Portuguese (Brazil) <https://translations.herculino.com/" "Language-Team: Portuguese (Brazil) <https://translations.herculino.com/"
@@ -605,11 +605,11 @@ msgstr "Intervalo"
msgid "Last Successful Fetch" msgid "Last Successful Fetch"
msgstr "Última execução bem-sucedida" msgstr "Última execução bem-sucedida"
#: apps/currencies/models.py:141 #: apps/currencies/models.py:143
msgid "Target Currencies" msgid "Target Currencies"
msgstr "Moedas-alvo" msgstr "Moedas-alvo"
#: apps/currencies/models.py:143 #: apps/currencies/models.py:145
msgid "" msgid ""
"Select currencies to fetch exchange rates for. Rates will be fetched for " "Select currencies to fetch exchange rates for. Rates will be fetched for "
"each currency against their set exchange currency." "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 " "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." "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" msgid "Target Accounts"
msgstr "Contas-alvo" msgstr "Contas-alvo"
#: apps/currencies/models.py:153 #: apps/currencies/models.py:155
msgid "" msgid ""
"Select accounts to fetch exchange rates for. Rates will be fetched for each " "Select accounts to fetch exchange rates for. Rates will be fetched for each "
"account's currency against their set exchange currency." "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 " "serão obtidas para a moeda de cada conta em relação à moeda de câmbio "
"definida." "definida."
#: apps/currencies/models.py:160 #: apps/currencies/models.py:162
msgid "Single exchange rate" msgid "Single exchange rate"
msgstr "Taxa de câmbio única" 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." msgid "Create one exchange rate and keep updating it. Avoids database clutter."
msgstr "" msgstr ""
"Cria uma taxa de câmbio e mantenha-a atualizada. Evita a poluição do banco " "Cria uma taxa de câmbio e mantenha-a atualizada. Evita a poluição do banco "
"de dados." "de dados."
#: apps/currencies/models.py:168 #: apps/currencies/models.py:170
msgid "Exchange Rate Service" msgid "Exchange Rate Service"
msgstr "Serviço de Taxa de Câmbio" msgstr "Serviço de Taxa de Câmbio"
#: apps/currencies/models.py:169 #: apps/currencies/models.py:171
msgid "Exchange Rate Services" msgid "Exchange Rate Services"
msgstr "Serviços de Taxa de Câmbio" 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." msgid "'Every X hours' interval type requires a positive integer."
msgstr "" msgstr ""
"Intervalo do tipo 'A cada X horas' requerer um número inteiro positivo." "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." 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." 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 "" msgid ""
"Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., " "Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., "
"'1-5,8,10-12')." "'1-5,8,10-12')."
@@ -665,7 +665,7 @@ msgstr ""
"Formato inválido de hora. Use uma lista de horas separada por vírgulas " "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')." "(0-23) e/ou uma faixa (ex.: '1-5,8,10-12')."
#: apps/currencies/models.py:255 #: apps/currencies/models.py:257
msgid "" msgid ""
"Invalid format. Please check the requirements for your selected interval " "Invalid format. Please check the requirements for your selected interval "
"type." "type."
@@ -2689,15 +2689,22 @@ msgstr "Alvos"
msgid "Last fetch" msgid "Last fetch"
msgstr "Última execução" 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" msgid "currencies"
msgstr "moedas" msgstr "moedas"
#: templates/exchange_rates_services/fragments/list.html:61 #: templates/exchange_rates_services/fragments/list.html:69
msgid "accounts" msgid "accounts"
msgstr "contas" msgstr "contas"
#: templates/exchange_rates_services/fragments/list.html:69 #: templates/exchange_rates_services/fragments/list.html:77
msgid "No services configured" msgid "No services configured"
msgstr "Nenhum serviço configurado" msgstr "Nenhum serviço configurado"

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \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" "PO-Revision-Date: 2025-04-14 06:16+0000\n"
"Last-Translator: Emil <emil.bjorkroth@gmail.com>\n" "Last-Translator: Emil <emil.bjorkroth@gmail.com>\n"
"Language-Team: Swedish <https://translations.herculino.com/projects/wygiwyh/" "Language-Team: Swedish <https://translations.herculino.com/projects/wygiwyh/"
@@ -597,57 +597,57 @@ msgstr ""
msgid "Last Successful Fetch" msgid "Last Successful Fetch"
msgstr "" msgstr ""
#: apps/currencies/models.py:141 #: apps/currencies/models.py:143
msgid "Target Currencies" msgid "Target Currencies"
msgstr "" msgstr ""
#: apps/currencies/models.py:143 #: apps/currencies/models.py:145
msgid "" msgid ""
"Select currencies to fetch exchange rates for. Rates will be fetched for " "Select currencies to fetch exchange rates for. Rates will be fetched for "
"each currency against their set exchange currency." "each currency against their set exchange currency."
msgstr "" msgstr ""
#: apps/currencies/models.py:151 #: apps/currencies/models.py:153
msgid "Target Accounts" msgid "Target Accounts"
msgstr "" msgstr ""
#: apps/currencies/models.py:153 #: apps/currencies/models.py:155
msgid "" msgid ""
"Select accounts to fetch exchange rates for. Rates will be fetched for each " "Select accounts to fetch exchange rates for. Rates will be fetched for each "
"account's currency against their set exchange currency." "account's currency against their set exchange currency."
msgstr "" msgstr ""
#: apps/currencies/models.py:160 #: apps/currencies/models.py:162
msgid "Single exchange rate" msgid "Single exchange rate"
msgstr "" msgstr ""
#: apps/currencies/models.py:163 #: apps/currencies/models.py:165
msgid "Create one exchange rate and keep updating it. Avoids database clutter." msgid "Create one exchange rate and keep updating it. Avoids database clutter."
msgstr "" msgstr ""
#: apps/currencies/models.py:168 #: apps/currencies/models.py:170
msgid "Exchange Rate Service" msgid "Exchange Rate Service"
msgstr "" msgstr ""
#: apps/currencies/models.py:169 #: apps/currencies/models.py:171
msgid "Exchange Rate Services" msgid "Exchange Rate Services"
msgstr "" msgstr ""
#: apps/currencies/models.py:221 #: apps/currencies/models.py:223
msgid "'Every X hours' interval type requires a positive integer." msgid "'Every X hours' interval type requires a positive integer."
msgstr "" msgstr ""
#: apps/currencies/models.py:230 #: apps/currencies/models.py:232
msgid "'Every X hours' interval must be between 1 and 24." msgid "'Every X hours' interval must be between 1 and 24."
msgstr "" msgstr ""
#: apps/currencies/models.py:244 #: apps/currencies/models.py:246
msgid "" msgid ""
"Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., " "Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., "
"'1-5,8,10-12')." "'1-5,8,10-12')."
msgstr "" msgstr ""
#: apps/currencies/models.py:255 #: apps/currencies/models.py:257
msgid "" msgid ""
"Invalid format. Please check the requirements for your selected interval " "Invalid format. Please check the requirements for your selected interval "
"type." "type."
@@ -2650,15 +2650,22 @@ msgstr ""
msgid "Last fetch" msgid "Last fetch"
msgstr "" 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" msgid "currencies"
msgstr "" msgstr ""
#: templates/exchange_rates_services/fragments/list.html:61 #: templates/exchange_rates_services/fragments/list.html:69
msgid "accounts" msgid "accounts"
msgstr "" msgstr ""
#: templates/exchange_rates_services/fragments/list.html:69 #: templates/exchange_rates_services/fragments/list.html:77
msgid "No services configured" msgid "No services configured"
msgstr "" msgstr ""

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \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" "PO-Revision-Date: 2025-11-01 01:17+0000\n"
"Last-Translator: mlystopad <mlystopadt@gmail.com>\n" "Last-Translator: mlystopad <mlystopadt@gmail.com>\n"
"Language-Team: Ukrainian <https://translations.herculino.com/projects/" "Language-Team: Ukrainian <https://translations.herculino.com/projects/"
@@ -615,11 +615,11 @@ msgstr "Інтервал"
msgid "Last Successful Fetch" msgid "Last Successful Fetch"
msgstr "Остання успішна вибірка" msgstr "Остання успішна вибірка"
#: apps/currencies/models.py:141 #: apps/currencies/models.py:143
msgid "Target Currencies" msgid "Target Currencies"
msgstr "Цільові валюти" msgstr "Цільові валюти"
#: apps/currencies/models.py:143 #: apps/currencies/models.py:145
msgid "" msgid ""
"Select currencies to fetch exchange rates for. Rates will be fetched for " "Select currencies to fetch exchange rates for. Rates will be fetched for "
"each currency against their set exchange currency." "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" msgid "Target Accounts"
msgstr "Цільові Рахунки" msgstr "Цільові Рахунки"
#: apps/currencies/models.py:153 #: apps/currencies/models.py:155
msgid "" msgid ""
"Select accounts to fetch exchange rates for. Rates will be fetched for each " "Select accounts to fetch exchange rates for. Rates will be fetched for each "
"account's currency against their set exchange currency." "account's currency against their set exchange currency."
@@ -639,35 +639,35 @@ msgstr ""
"Оберіть рахунки для завантаження курсів обміну. Курси будуть завантажені для " "Оберіть рахунки для завантаження курсів обміну. Курси будуть завантажені для "
"валюти кожного рахунку відносно встановленої валюти обміну." "валюти кожного рахунку відносно встановленої валюти обміну."
#: apps/currencies/models.py:160 #: apps/currencies/models.py:162
#, fuzzy #, fuzzy
#| msgid "Exchange Rate" #| msgid "Exchange Rate"
msgid "Single exchange rate" msgid "Single exchange rate"
msgstr "Обмінний курс" msgstr "Обмінний курс"
#: apps/currencies/models.py:163 #: apps/currencies/models.py:165
msgid "Create one exchange rate and keep updating it. Avoids database clutter." msgid "Create one exchange rate and keep updating it. Avoids database clutter."
msgstr "" msgstr ""
"Створіть один курс обміну та оновлюйте його постійно. Це запобігає " "Створіть один курс обміну та оновлюйте його постійно. Це запобігає "
"засміченню бази даних." "засміченню бази даних."
#: apps/currencies/models.py:168 #: apps/currencies/models.py:170
msgid "Exchange Rate Service" msgid "Exchange Rate Service"
msgstr "Сервіс Курсів Обміну" msgstr "Сервіс Курсів Обміну"
#: apps/currencies/models.py:169 #: apps/currencies/models.py:171
msgid "Exchange Rate Services" msgid "Exchange Rate Services"
msgstr "Сервіси Курсів Обміну" msgstr "Сервіси Курсів Обміну"
#: apps/currencies/models.py:221 #: apps/currencies/models.py:223
msgid "'Every X hours' interval type requires a positive integer." msgid "'Every X hours' interval type requires a positive integer."
msgstr "Інтервал типу «Кожні X годин» потребує додатнього цілого числа." msgstr "Інтервал типу «Кожні X годин» потребує додатнього цілого числа."
#: apps/currencies/models.py:230 #: apps/currencies/models.py:232
msgid "'Every X hours' interval must be between 1 and 24." msgid "'Every X hours' interval must be between 1 and 24."
msgstr "Інтервал типу «Кожні X годин» повинен бути між 1 та 24." msgstr "Інтервал типу «Кожні X годин» повинен бути між 1 та 24."
#: apps/currencies/models.py:244 #: apps/currencies/models.py:246
msgid "" msgid ""
"Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., " "Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., "
"'1-5,8,10-12')." "'1-5,8,10-12')."
@@ -675,7 +675,7 @@ msgstr ""
"Неправильний формат годин. Використовуйте години, розділені комами (023) та/" "Неправильний формат годин. Використовуйте години, розділені комами (023) та/"
"або діапазони (наприклад, '1-5,8,10-12')." "або діапазони (наприклад, '1-5,8,10-12')."
#: apps/currencies/models.py:255 #: apps/currencies/models.py:257
msgid "" msgid ""
"Invalid format. Please check the requirements for your selected interval " "Invalid format. Please check the requirements for your selected interval "
"type." "type."
@@ -2686,15 +2686,23 @@ msgstr ""
msgid "Last fetch" msgid "Last fetch"
msgstr "" 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" msgid "currencies"
msgstr "" msgstr ""
#: templates/exchange_rates_services/fragments/list.html:61 #: templates/exchange_rates_services/fragments/list.html:69
msgid "accounts" msgid "accounts"
msgstr "" msgstr ""
#: templates/exchange_rates_services/fragments/list.html:69 #: templates/exchange_rates_services/fragments/list.html:77
msgid "No services configured" msgid "No services configured"
msgstr "" msgstr ""

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \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" "PO-Revision-Date: 2025-10-08 16:17+0000\n"
"Last-Translator: doody <doodykimo@gmail.com>\n" "Last-Translator: doody <doodykimo@gmail.com>\n"
"Language-Team: Chinese (Traditional Han script) <https://translations." "Language-Team: Chinese (Traditional Han script) <https://translations."
@@ -595,51 +595,51 @@ msgstr "間隔"
msgid "Last Successful Fetch" msgid "Last Successful Fetch"
msgstr "最後更新時間" msgstr "最後更新時間"
#: apps/currencies/models.py:141 #: apps/currencies/models.py:143
msgid "Target Currencies" msgid "Target Currencies"
msgstr "目標貨幣" msgstr "目標貨幣"
#: apps/currencies/models.py:143 #: apps/currencies/models.py:145
msgid "" msgid ""
"Select currencies to fetch exchange rates for. Rates will be fetched for " "Select currencies to fetch exchange rates for. Rates will be fetched for "
"each currency against their set exchange currency." "each currency against their set exchange currency."
msgstr "選擇要自動擷取匯率的貨幣,貨幣會根據設定的目標貨幣自動取得匯率資訊。" msgstr "選擇要自動擷取匯率的貨幣,貨幣會根據設定的目標貨幣自動取得匯率資訊。"
#: apps/currencies/models.py:151 #: apps/currencies/models.py:153
msgid "Target Accounts" msgid "Target Accounts"
msgstr "目標帳戶" msgstr "目標帳戶"
#: apps/currencies/models.py:153 #: apps/currencies/models.py:155
msgid "" msgid ""
"Select accounts to fetch exchange rates for. Rates will be fetched for each " "Select accounts to fetch exchange rates for. Rates will be fetched for each "
"account's currency against their set exchange currency." "account's currency against their set exchange currency."
msgstr "選擇自動擷取匯率的帳戶,帳戶會根據設定的匯兌貨幣取得匯率資訊。" msgstr "選擇自動擷取匯率的帳戶,帳戶會根據設定的匯兌貨幣取得匯率資訊。"
#: apps/currencies/models.py:160 #: apps/currencies/models.py:162
msgid "Single exchange rate" msgid "Single exchange rate"
msgstr "保留單一匯率資訊" msgstr "保留單一匯率資訊"
#: apps/currencies/models.py:163 #: apps/currencies/models.py:165
msgid "Create one exchange rate and keep updating it. Avoids database clutter." msgid "Create one exchange rate and keep updating it. Avoids database clutter."
msgstr "只建立一筆匯率資訊並且持續更新,防止資料庫無限擴張。" msgstr "只建立一筆匯率資訊並且持續更新,防止資料庫無限擴張。"
#: apps/currencies/models.py:168 #: apps/currencies/models.py:170
msgid "Exchange Rate Service" msgid "Exchange Rate Service"
msgstr "匯率資訊服務" msgstr "匯率資訊服務"
#: apps/currencies/models.py:169 #: apps/currencies/models.py:171
msgid "Exchange Rate Services" msgid "Exchange Rate Services"
msgstr "匯率資訊服務" msgstr "匯率資訊服務"
#: apps/currencies/models.py:221 #: apps/currencies/models.py:223
msgid "'Every X hours' interval type requires a positive integer." msgid "'Every X hours' interval type requires a positive integer."
msgstr "「每X小時」需要提供正整數。" msgstr "「每X小時」需要提供正整數。"
#: apps/currencies/models.py:230 #: apps/currencies/models.py:232
msgid "'Every X hours' interval must be between 1 and 24." msgid "'Every X hours' interval must be between 1 and 24."
msgstr "「每X小時」需要介於1到24之間。" msgstr "「每X小時」需要介於1到24之間。"
#: apps/currencies/models.py:244 #: apps/currencies/models.py:246
msgid "" msgid ""
"Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., " "Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., "
"'1-5,8,10-12')." "'1-5,8,10-12')."
@@ -647,7 +647,7 @@ msgstr ""
"錯誤的小時格式請使用逗號設定多個小時0~23或著設定範圍例" "錯誤的小時格式請使用逗號設定多個小時0~23或著設定範圍例"
"如:'1-5,10-12')。" "如:'1-5,10-12')。"
#: apps/currencies/models.py:255 #: apps/currencies/models.py:257
msgid "" msgid ""
"Invalid format. Please check the requirements for your selected interval " "Invalid format. Please check the requirements for your selected interval "
"type." "type."
@@ -2654,15 +2654,21 @@ msgstr "目標"
msgid "Last fetch" msgid "Last fetch"
msgstr "最後更新" 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" msgid "currencies"
msgstr "貨幣" msgstr "貨幣"
#: templates/exchange_rates_services/fragments/list.html:61 #: templates/exchange_rates_services/fragments/list.html:69
msgid "accounts" msgid "accounts"
msgstr "帳戶" msgstr "帳戶"
#: templates/exchange_rates_services/fragments/list.html:69 #: templates/exchange_rates_services/fragments/list.html:77
msgid "No services configured" msgid "No services configured"
msgstr "沒有設定任何服務" msgstr "沒有設定任何服務"

View File

@@ -56,7 +56,15 @@
</td> </td>
<td class="table-col-auto">{% if service.is_active %}<i class="fa-solid fa-circle text-success"></i>{% else %} <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> <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.get_service_type_display }}</td>
<td>{{ service.target_currencies.count }} {% trans 'currencies' %}, {{ service.target_accounts.count }} {% trans 'accounts' %}</td> <td>{{ service.target_currencies.count }} {% trans 'currencies' %}, {{ service.target_accounts.count }} {% trans 'accounts' %}</td>
<td>{{ service.last_fetch|date:"SHORT_DATETIME_FORMAT" }}</td> <td>{{ service.last_fetch|date:"SHORT_DATETIME_FORMAT" }}</td>