Compare commits

..

32 Commits

Author SHA1 Message Date
Herculino Trotta
16de87376a Merge pull request #429
fix(api): inefficient transaction update operation
2025-12-07 13:55:29 -03:00
Herculino Trotta
e8e1144fdd fix(api): inefficient transaction update operation 2025-12-07 13:53:30 -03:00
eitchtee
157f357a7a chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-12-07 16:45:08 +00:00
Herculino Trotta
d77eddbd26 Merge pull request #428 from SerafimPikalov/fix/null-category-serialization
fix: handle null category in TransactionCategoryField serialization
2025-12-07 13:44:25 -03:00
Sera Pikalov
fb1b383962 fix: handle null category in TransactionCategoryField serialization
Fix AttributeError when serializing transactions with null categories.
The to_representation method now checks for None before accessing
category properties, returning None instead of crashing.

Fixes issue where API returns 500 error when retrieving transactions
without assigned categories.
2025-12-07 12:37:05 +02:00
eitchtee
11998475c5 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-12-07 03:33:34 +00:00
Herculino Trotta
21363e23a1 Merge pull request #425
feat(api): add endpoints for importing files and getting account balance
2025-12-07 00:33:00 -03:00
Herculino Trotta
d3a816d91b feat(api): add endpoints for importing files and getting account balance 2025-12-07 00:32:18 -03:00
Herculino Trotta
9c92bbd3cf Merge pull request #424
fix(import:v1): always_* types for is_paid and type requires assigning a source
2025-12-06 17:53:15 -03:00
Herculino Trotta
c55d688956 fix(import:v1): always_* types for is_paid and type requires assigning a source 2025-12-06 17:52:46 -03:00
Herculino Trotta
231b9065c9 Merge pull request #423
fix: decouple DEBUG env variable from vite dev server
2025-12-06 17:33:14 -03:00
Herculino Trotta
01ea0de4b3 fix: decouple DEBUG env variable from vite dev server 2025-12-06 17:32:34 -03:00
Herculino Trotta
c57fa1630b Merge pull request #422
fix: try to fix "the connection is closed" db errors
2025-12-06 16:47:13 -03:00
Herculino Trotta
92f7bcfd9e fix: try to fix "the connection is closed" db errors 2025-12-06 16:46:33 -03:00
eitchtee
58b855f55e chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-12-06 19:19:20 +00:00
Herculino Trotta
d4d51301b3 Merge pull request #421
feat: accept query params on standalone add transaction page
2025-12-06 16:18:50 -03:00
Herculino Trotta
aed3fb11fe feat: accept query params on standalone add transaction page 2025-12-06 16:17:37 -03:00
Herculino Trotta
70d427bec4 Merge pull request #420
chore: bump dependencies
2025-12-06 14:23:43 -03:00
Herculino Trotta
b6f52458db chore: bump dependencies 2025-12-06 14:22:56 -03:00
Herculino Trotta
8d76c40b7e Merge pull request #419 from eitchtee/dev
chore: bump dependencies for safety
2025-12-06 14:05:25 -03:00
Herculino Trotta
a43e3d158f chore: bump dependencies for safety 2025-12-06 14:02:37 -03:00
Herculino Trotta
588ae2de6e Merge pull request #407 from eitchtee/weblate
Translations update from Weblate
2025-11-26 21:44:07 -03:00
Herculino Trotta
4b97ba681a Merge branch 'main' into weblate 2025-11-26 21:43:56 -03:00
eitchtee
1a903507ad chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-11-26 19:41:38 +00:00
Weblate
bf920df771 Merge remote-tracking branch 'origin/main' 2025-11-26 19:41:04 +00:00
Herculino Trotta
23ae6f3d54 Merge pull request #411
fix: unable to create transactions with an empty reference date when importing
2025-11-26 16:41:01 -03:00
Herculino Trotta
49f28834e9 fix: unable to create transactions with an empty reference date when importing
fixes #410
2025-11-26 16:39:51 -03:00
Dimitri Decrock
4351027b87 locale(Dutch): update translation
Currently translated at 100.0% (695 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-11-25 05:20:40 +00:00
Herculino Trotta
c37aa6e059 Merge pull request #404 from eitchtee/weblate
Translations update from Weblate
2025-11-24 00:43:55 -03:00
Herculino Trotta
8a5a54dcbd locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (695 of 695 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-11-24 03:20:40 +00:00
eitchtee
24ee8ecd68 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-11-24 02:56:47 +00:00
Herculino Trotta
a14332bb80 Merge pull request #402 from eitchtee/feat/replace-webpack+bootstrap
feat: replace webpack with vite and bootstrap with daisyui
2025-11-23 23:56:02 -03:00
38 changed files with 11756 additions and 10811 deletions

View File

@@ -127,7 +127,7 @@ To create the first user, open the container's console using Unraid's UI, by cli
| variable | type | default | explanation |
|-------------------------------|-------------|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| INTERNAL_PORT | int | 8000 | The port on which the app listens on. Defaults to 8000 if not set. |
| INTERNAL_PORT | int | 8000 | The port on which the app listens on. Defaults to 8000 if not set. |
| DJANGO_ALLOWED_HOSTS | string | localhost 127.0.0.1 | A list of space separated domains and IPs representing the host/domain names that WYGIWYH site can serve. [Click here](https://docs.djangoproject.com/en/5.1/ref/settings/#allowed-hosts) for more details |
| HTTPS_ENABLED | true\|false | false | Whether to use secure cookies. If this is set to true, the cookie will be marked as “secure”, which means browsers may ensure that the cookie is only sent under an HTTPS connection |
| URL | string | http://localhost http://127.0.0.1 | A list of space separated domains and IPs (with the protocol) representing the trusted origins for unsafe requests (e.g. POST). [Click here](https://docs.djangoproject.com/en/5.1/ref/settings/#csrf-trusted-origins ) for more details |
@@ -145,7 +145,10 @@ To create the first user, open the container's console using Unraid's UI, by cli
| DEMO | true\|false | false | If demo mode is enabled. |
| ADMIN_EMAIL | string | None | Automatically creates an admin account with this email. Must have `ADMIN_PASSWORD` also set. |
| ADMIN_PASSWORD | string | None | Automatically creates an admin account with this password. Must have `ADMIN_EMAIL` also set. |
| CHECK_FOR_UPDATES | bool | true | Check and notify users about new versions. The check is done by doing a single query to Github's API every 12 hours. |
| CHECK_FOR_UPDATES | true\|false | true | Check and notify users about new versions. The check is done by doing a single query to Github's API every 12 hours. |
| DJANGO_VITE_DEV_MODE | true\|false | false | Enables Vite dev server mode for frontend development. When true, assets are served from Vite's dev server instead of the build manifest. For development only! |
| DJANGO_VITE_DEV_SERVER_PORT | int | 5173 | The port where Vite's dev server is running. Only used when DJANGO_VITE_DEV_MODE is true. For development only! |
| DJANGO_VITE_DEV_SERVER_HOST | string | localhost | The host where Vite's dev server is running. Only used when DJANGO_VITE_DEV_MODE is true. For development only! |
## OIDC Configuration

View File

@@ -151,6 +151,9 @@ DATABASES = {
"PASSWORD": os.getenv("SQL_PASSWORD", "password"),
"HOST": os.getenv("SQL_HOST", "localhost"),
"PORT": os.getenv("SQL_PORT", "5432"),
"OPTIONS": {
"pool": True,
},
}
}
@@ -316,9 +319,9 @@ CACHES = {
DJANGO_VITE_ASSETS_PATH = STATIC_ROOT
DJANGO_VITE_MANIFEST_PATH = DJANGO_VITE_ASSETS_PATH / "manifest.json"
DJANGO_VITE_DEV_MODE = DEBUG
DJANGO_VITE_DEV_SERVER_PORT = 5173
DJANGO_VITE_DEV_SERVER_HOST = "localhost"
DJANGO_VITE_DEV_MODE = os.getenv("DJANGO_VITE_DEV_MODE", "false").lower() == "true"
DJANGO_VITE_DEV_SERVER_PORT = int(os.getenv("DJANGO_VITE_DEV_SERVER_PORT", "5173"))
DJANGO_VITE_DEV_SERVER_HOST = os.getenv("DJANGO_VITE_DEV_SERVER_HOST", "localhost")
# Default primary key field type
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field

View File

@@ -0,0 +1,33 @@
from decimal import Decimal
from django.db import models
from apps.accounts.models import Account
from apps.transactions.models import Transaction
def get_account_balance(account: Account, paid_only: bool = True) -> Decimal:
"""
Calculate account balance (income - expense).
Args:
account: Account instance to calculate balance for.
paid_only: If True, only count paid transactions (current balance).
If False, count all transactions (projected balance).
Returns:
Decimal: The calculated balance (income - expense).
"""
filters = {"account": account}
if paid_only:
filters["is_paid"] = True
income = Transaction.objects.filter(
type=Transaction.Type.INCOME, **filters
).aggregate(total=models.Sum("amount"))["total"] or Decimal("0")
expense = Transaction.objects.filter(
type=Transaction.Type.EXPENSE, **filters
).aggregate(total=models.Sum("amount"))["total"] or Decimal("0")
return income - expense

View File

@@ -1,3 +1,5 @@
from datetime import date
from django.test import TestCase
from apps.accounts.models import Account, AccountGroup
@@ -39,3 +41,135 @@ class AccountTests(TestCase):
exchange_currency=self.exchange_currency,
)
self.assertEqual(account.exchange_currency, self.exchange_currency)
class GetAccountBalanceServiceTests(TestCase):
"""Tests for the get_account_balance service function"""
def setUp(self):
"""Set up test data"""
from apps.transactions.models import Transaction
self.Transaction = Transaction
self.currency = Currency.objects.create(
code="BRL", name="Brazilian Real", decimal_places=2, prefix="R$ "
)
self.account_group = AccountGroup.objects.create(name="Service Test Group")
self.account = Account.objects.create(
name="Service Test Account", group=self.account_group, currency=self.currency
)
def test_balance_with_no_transactions(self):
"""Test balance is 0 when no transactions exist"""
from apps.accounts.services import get_account_balance
from decimal import Decimal
balance = get_account_balance(self.account, paid_only=True)
self.assertEqual(balance, Decimal("0"))
def test_current_balance_only_counts_paid(self):
"""Test current balance only counts paid transactions"""
from apps.accounts.services import get_account_balance
from decimal import Decimal
# Paid income
self.Transaction.objects.create(
account=self.account,
type=self.Transaction.Type.INCOME,
amount=Decimal("100.00"),
is_paid=True,
date=date(2025, 1, 1),
description="Paid income",
)
# Unpaid income (should not count)
self.Transaction.objects.create(
account=self.account,
type=self.Transaction.Type.INCOME,
amount=Decimal("50.00"),
is_paid=False,
date=date(2025, 1, 1),
description="Unpaid income",
)
# Paid expense
self.Transaction.objects.create(
account=self.account,
type=self.Transaction.Type.EXPENSE,
amount=Decimal("30.00"),
is_paid=True,
date=date(2025, 1, 1),
description="Paid expense",
)
balance = get_account_balance(self.account, paid_only=True)
self.assertEqual(balance, Decimal("70.00")) # 100 - 30
def test_projected_balance_counts_all(self):
"""Test projected balance counts all transactions"""
from apps.accounts.services import get_account_balance
from decimal import Decimal
# Paid income
self.Transaction.objects.create(
account=self.account,
type=self.Transaction.Type.INCOME,
amount=Decimal("100.00"),
is_paid=True,
date=date(2025, 1, 1),
description="Paid income",
)
# Unpaid income
self.Transaction.objects.create(
account=self.account,
type=self.Transaction.Type.INCOME,
amount=Decimal("50.00"),
is_paid=False,
date=date(2025, 1, 1),
description="Unpaid income",
)
# Paid expense
self.Transaction.objects.create(
account=self.account,
type=self.Transaction.Type.EXPENSE,
amount=Decimal("30.00"),
is_paid=True,
date=date(2025, 1, 1),
description="Paid expense",
)
# Unpaid expense
self.Transaction.objects.create(
account=self.account,
type=self.Transaction.Type.EXPENSE,
amount=Decimal("20.00"),
is_paid=False,
date=date(2025, 1, 1),
description="Unpaid expense",
)
balance = get_account_balance(self.account, paid_only=False)
self.assertEqual(balance, Decimal("100.00")) # (100 + 50) - (30 + 20)
def test_balance_defaults_to_paid_only(self):
"""Test that paid_only defaults to True"""
from apps.accounts.services import get_account_balance
from decimal import Decimal
self.Transaction.objects.create(
account=self.account,
type=self.Transaction.Type.INCOME,
amount=Decimal("100.00"),
is_paid=True,
date=date(2025, 1, 1),
description="Paid",
)
self.Transaction.objects.create(
account=self.account,
type=self.Transaction.Type.INCOME,
amount=Decimal("50.00"),
is_paid=False,
date=date(2025, 1, 1),
description="Unpaid",
)
balance = get_account_balance(self.account) # defaults to paid_only=True
self.assertEqual(balance, Decimal("100.00"))

View File

@@ -11,23 +11,13 @@ from django.utils.translation import gettext_lazy as _
from apps.accounts.forms import AccountBalanceFormSet
from apps.accounts.models import Account, Transaction
from apps.accounts.services import get_account_balance
from apps.common.decorators.htmx import only_htmx
@only_htmx
@login_required
def account_reconciliation(request):
def get_account_balance(account):
income = Transaction.objects.filter(
account=account, type=Transaction.Type.INCOME, is_paid=True
).aggregate(total=models.Sum("amount"))["total"] or Decimal("0")
expense = Transaction.objects.filter(
account=account, type=Transaction.Type.EXPENSE, is_paid=True
).aggregate(total=models.Sum("amount"))["total"] or Decimal("0")
return income - expense
initial_data = [
{
"account_id": account.id,

View File

@@ -10,15 +10,19 @@ from apps.transactions.models import (
@extend_schema_field(
{
"oneOf": [{"type": "string"}, {"type": "integer"}],
"description": "TransactionCategory ID or name. If the name doesn't exist, a new one will be created",
"oneOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}],
"description": "TransactionCategory ID or name. If the name doesn't exist, a new one will be created. Can be null if no category is assigned.",
}
)
class TransactionCategoryField(serializers.Field):
def to_representation(self, value):
if value is None:
return None
return {"id": value.id, "name": value.name}
def to_internal_value(self, data):
if data is None:
return None
if isinstance(data, int):
try:
return TransactionCategory.objects.get(pk=data)

View File

@@ -2,3 +2,5 @@ from .transactions import *
from .accounts import *
from .currencies import *
from .dca import *
from .imports import *

View File

@@ -67,3 +67,12 @@ class AccountSerializer(serializers.ModelSerializer):
setattr(instance, attr, value)
instance.save()
return instance
class AccountBalanceSerializer(serializers.Serializer):
"""Serializer for account balance response."""
current_balance = serializers.DecimalField(max_digits=20, decimal_places=10)
projected_balance = serializers.DecimalField(max_digits=20, decimal_places=10)
currency = CurrencySerializer()

View File

@@ -0,0 +1,41 @@
from rest_framework import serializers
from apps.import_app.models import ImportProfile, ImportRun
class ImportProfileSerializer(serializers.ModelSerializer):
"""Serializer for listing import profiles."""
class Meta:
model = ImportProfile
fields = ["id", "name", "version", "yaml_config"]
class ImportRunSerializer(serializers.ModelSerializer):
"""Serializer for listing import runs."""
class Meta:
model = ImportRun
fields = [
"id",
"status",
"profile",
"file_name",
"logs",
"processed_rows",
"total_rows",
"successful_rows",
"skipped_rows",
"failed_rows",
"started_at",
"finished_at",
]
class ImportFileSerializer(serializers.Serializer):
"""Serializer for uploading a file to import using an existing profile."""
profile_id = serializers.PrimaryKeyRelatedField(
queryset=ImportProfile.objects.all(), source="profile"
)
file = serializers.FileField()

View File

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

View File

@@ -0,0 +1,99 @@
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.transactions.models import Transaction
@override_settings(
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
},
},
WHITENOISE_AUTOREFRESH=True,
)
class AccountBalanceAPITests(TestCase):
"""Tests for the Account Balance API endpoint"""
def setUp(self):
"""Set up test data"""
User = get_user_model()
self.user = User.objects.create_user(
email="testuser@test.com", password="testpass123"
)
self.client = APIClient()
self.client.force_authenticate(user=self.user)
self.currency = Currency.objects.create(
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
)
self.account_group = AccountGroup.objects.create(name="Test Group")
self.account = Account.objects.create(
name="Test Account", group=self.account_group, currency=self.currency
)
# Create some transactions
Transaction.objects.create(
account=self.account,
type=Transaction.Type.INCOME,
amount=Decimal("500.00"),
is_paid=True,
date=date(2025, 1, 1),
description="Paid income",
)
Transaction.objects.create(
account=self.account,
type=Transaction.Type.INCOME,
amount=Decimal("200.00"),
is_paid=False,
date=date(2025, 1, 15),
description="Unpaid income",
)
Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
amount=Decimal("100.00"),
is_paid=True,
date=date(2025, 1, 10),
description="Paid expense",
)
def test_get_balance_success(self):
"""Test successful balance retrieval"""
response = self.client.get(f"/api/accounts/{self.account.id}/balance/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn("current_balance", response.data)
self.assertIn("projected_balance", response.data)
self.assertIn("currency", response.data)
# Current: 500 - 100 = 400
self.assertEqual(Decimal(response.data["current_balance"]), Decimal("400.00"))
# Projected: (500 + 200) - 100 = 600
self.assertEqual(Decimal(response.data["projected_balance"]), Decimal("600.00"))
# Check currency data
self.assertEqual(response.data["currency"]["code"], "USD")
def test_get_balance_nonexistent_account(self):
"""Test balance for non-existent account returns 404"""
response = self.client.get("/api/accounts/99999/balance/")
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_get_balance_unauthenticated(self):
"""Test unauthenticated request returns 403"""
unauthenticated_client = APIClient()
response = unauthenticated_client.get(
f"/api/accounts/{self.account.id}/balance/"
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

View File

@@ -0,0 +1,404 @@
from io import BytesIO
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from rest_framework import status
from rest_framework.test import APIClient
from apps.import_app.models import ImportProfile, ImportRun
@override_settings(
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
},
},
WHITENOISE_AUTOREFRESH=True,
)
class ImportAPITests(TestCase):
"""Tests for the Import API endpoint"""
def setUp(self):
"""Set up test data"""
User = get_user_model()
self.user = User.objects.create_user(
email="testuser@test.com", password="testpass123"
)
self.client = APIClient()
self.client.force_authenticate(user=self.user)
# Create a basic import profile with minimal valid YAML config
self.profile = ImportProfile.objects.create(
name="Test Profile",
version=ImportProfile.Versions.VERSION_1,
yaml_config="""
file_type: csv
date_format: "%Y-%m-%d"
column_mapping:
date:
source: date
description:
source: description
amount:
source: amount
transaction_type:
detection_method: always_expense
is_paid:
detection_method: always_paid
account:
source: account
match_field: name
""",
)
@patch("apps.import_app.tasks.process_import.defer")
@patch("django.core.files.storage.FileSystemStorage.save")
@patch("django.core.files.storage.FileSystemStorage.path")
def test_create_import_success(self, mock_path, mock_save, mock_defer):
"""Test successful file upload creates ImportRun and queues task"""
mock_save.return_value = "test_file.csv"
mock_path.return_value = "/usr/src/app/temp/test_file.csv"
csv_content = b"date,description,amount,account\n2025-01-01,Test,100,Main"
file = SimpleUploadedFile(
"test_file.csv", csv_content, content_type="text/csv"
)
response = self.client.post(
"/api/import/import/",
{"profile_id": self.profile.id, "file": file},
format="multipart",
)
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
self.assertIn("import_run_id", response.data)
self.assertEqual(response.data["status"], "queued")
# Verify ImportRun was created
import_run = ImportRun.objects.get(id=response.data["import_run_id"])
self.assertEqual(import_run.profile, self.profile)
self.assertEqual(import_run.file_name, "test_file.csv")
# Verify task was deferred
mock_defer.assert_called_once_with(
import_run_id=import_run.id,
file_path="/usr/src/app/temp/test_file.csv",
user_id=self.user.id,
)
def test_create_import_missing_profile(self):
"""Test request without profile_id returns 400"""
csv_content = b"date,description,amount\n2025-01-01,Test,100"
file = SimpleUploadedFile(
"test_file.csv", csv_content, content_type="text/csv"
)
response = self.client.post(
"/api/import/import/",
{"file": file},
format="multipart",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("profile_id", response.data)
def test_create_import_missing_file(self):
"""Test request without file returns 400"""
response = self.client.post(
"/api/import/import/",
{"profile_id": self.profile.id},
format="multipart",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("file", response.data)
def test_create_import_invalid_profile(self):
"""Test request with non-existent profile returns 400"""
csv_content = b"date,description,amount\n2025-01-01,Test,100"
file = SimpleUploadedFile(
"test_file.csv", csv_content, content_type="text/csv"
)
response = self.client.post(
"/api/import/import/",
{"profile_id": 99999, "file": file},
format="multipart",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("profile_id", response.data)
@patch("apps.import_app.tasks.process_import.defer")
@patch("django.core.files.storage.FileSystemStorage.save")
@patch("django.core.files.storage.FileSystemStorage.path")
def test_create_import_xlsx(self, mock_path, mock_save, mock_defer):
"""Test successful XLSX file upload"""
mock_save.return_value = "test_file.xlsx"
mock_path.return_value = "/usr/src/app/temp/test_file.xlsx"
# Create a simple XLSX-like content (just for the upload test)
xlsx_content = BytesIO(b"PK\x03\x04") # XLSX files start with PK header
file = SimpleUploadedFile(
"test_file.xlsx",
xlsx_content.getvalue(),
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
response = self.client.post(
"/api/import/import/",
{"profile_id": self.profile.id, "file": file},
format="multipart",
)
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
self.assertIn("import_run_id", response.data)
def test_unauthenticated_request(self):
"""Test unauthenticated request returns 403"""
unauthenticated_client = APIClient()
csv_content = b"date,description,amount\n2025-01-01,Test,100"
file = SimpleUploadedFile(
"test_file.csv", csv_content, content_type="text/csv"
)
response = unauthenticated_client.post(
"/api/import/import/",
{"profile_id": self.profile.id, "file": file},
format="multipart",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
@override_settings(
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
},
},
WHITENOISE_AUTOREFRESH=True,
)
class ImportProfileAPITests(TestCase):
"""Tests for the Import Profile API endpoints"""
def setUp(self):
"""Set up test data"""
User = get_user_model()
self.user = User.objects.create_user(
email="testuser@test.com", password="testpass123"
)
self.client = APIClient()
self.client.force_authenticate(user=self.user)
self.profile1 = ImportProfile.objects.create(
name="Profile 1",
version=ImportProfile.Versions.VERSION_1,
yaml_config="""
file_type: csv
date_format: "%Y-%m-%d"
column_mapping:
date:
source: date
description:
source: description
amount:
source: amount
transaction_type:
detection_method: always_expense
is_paid:
detection_method: always_paid
account:
source: account
match_field: name
""",
)
self.profile2 = ImportProfile.objects.create(
name="Profile 2",
version=ImportProfile.Versions.VERSION_1,
yaml_config="""
file_type: csv
date_format: "%Y-%m-%d"
column_mapping:
date:
source: date
description:
source: description
amount:
source: amount
transaction_type:
detection_method: always_income
is_paid:
detection_method: always_unpaid
account:
source: account
match_field: name
""",
)
def test_list_profiles(self):
"""Test listing all profiles"""
response = self.client.get("/api/import/profiles/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["count"], 2)
self.assertEqual(len(response.data["results"]), 2)
def test_retrieve_profile(self):
"""Test retrieving a specific profile"""
response = self.client.get(f"/api/import/profiles/{self.profile1.id}/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["id"], self.profile1.id)
self.assertEqual(response.data["name"], "Profile 1")
self.assertIn("yaml_config", response.data)
def test_retrieve_nonexistent_profile(self):
"""Test retrieving a non-existent profile returns 404"""
response = self.client.get("/api/import/profiles/99999/")
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_profiles_unauthenticated(self):
"""Test unauthenticated request returns 403"""
unauthenticated_client = APIClient()
response = unauthenticated_client.get("/api/import/profiles/")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
@override_settings(
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
},
},
WHITENOISE_AUTOREFRESH=True,
)
class ImportRunAPITests(TestCase):
"""Tests for the Import Run API endpoints"""
def setUp(self):
"""Set up test data"""
User = get_user_model()
self.user = User.objects.create_user(
email="testuser@test.com", password="testpass123"
)
self.client = APIClient()
self.client.force_authenticate(user=self.user)
self.profile1 = ImportProfile.objects.create(
name="Profile 1",
version=ImportProfile.Versions.VERSION_1,
yaml_config="""
file_type: csv
date_format: "%Y-%m-%d"
column_mapping:
date:
source: date
description:
source: description
amount:
source: amount
transaction_type:
detection_method: always_expense
is_paid:
detection_method: always_paid
account:
source: account
match_field: name
""",
)
self.profile2 = ImportProfile.objects.create(
name="Profile 2",
version=ImportProfile.Versions.VERSION_1,
yaml_config="""
file_type: csv
date_format: "%Y-%m-%d"
column_mapping:
date:
source: date
description:
source: description
amount:
source: amount
transaction_type:
detection_method: always_income
is_paid:
detection_method: always_unpaid
account:
source: account
match_field: name
""",
)
# Create import runs
self.run1 = ImportRun.objects.create(
profile=self.profile1,
file_name="file1.csv",
status=ImportRun.Status.FINISHED,
)
self.run2 = ImportRun.objects.create(
profile=self.profile1,
file_name="file2.csv",
status=ImportRun.Status.QUEUED,
)
self.run3 = ImportRun.objects.create(
profile=self.profile2,
file_name="file3.csv",
status=ImportRun.Status.FINISHED,
)
def test_list_all_runs(self):
"""Test listing all runs"""
response = self.client.get("/api/import/runs/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["count"], 3)
self.assertEqual(len(response.data["results"]), 3)
def test_list_runs_by_profile(self):
"""Test filtering runs by profile_id"""
response = self.client.get(f"/api/import/runs/?profile_id={self.profile1.id}")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["count"], 2)
for run in response.data["results"]:
self.assertEqual(run["profile"], self.profile1.id)
def test_list_runs_by_other_profile(self):
"""Test filtering runs by another profile_id"""
response = self.client.get(f"/api/import/runs/?profile_id={self.profile2.id}")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["count"], 1)
self.assertEqual(response.data["results"][0]["profile"], self.profile2.id)
def test_retrieve_run(self):
"""Test retrieving a specific run"""
response = self.client.get(f"/api/import/runs/{self.run1.id}/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["id"], self.run1.id)
self.assertEqual(response.data["file_name"], "file1.csv")
self.assertEqual(response.data["status"], "FINISHED")
def test_retrieve_nonexistent_run(self):
"""Test retrieving a non-existent run returns 404"""
response = self.client.get("/api/import/runs/99999/")
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_runs_unauthenticated(self):
"""Test unauthenticated request returns 403"""
unauthenticated_client = APIClient()
response = unauthenticated_client.get("/api/import/runs/")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

View File

@@ -16,7 +16,11 @@ router.register(r"currencies", views.CurrencyViewSet)
router.register(r"exchange-rates", views.ExchangeRateViewSet)
router.register(r"dca/strategies", views.DCAStrategyViewSet)
router.register(r"dca/entries", views.DCAEntryViewSet)
router.register(r"import/profiles", views.ImportProfileViewSet, basename="import-profiles")
router.register(r"import/runs", views.ImportRunViewSet, basename="import-runs")
router.register(r"import/import", views.ImportViewSet, basename="import-import")
urlpatterns = [
path("", include(router.urls)),
]

View File

@@ -2,3 +2,5 @@ from .transactions import *
from .accounts import *
from .currencies import *
from .dca import *
from .imports import *

View File

@@ -1,11 +1,18 @@
from drf_spectacular.utils import extend_schema, extend_schema_view
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from apps.api.custom.pagination import CustomPageNumberPagination
from apps.accounts.models import AccountGroup, Account
from apps.api.serializers import AccountGroupSerializer, AccountSerializer
from apps.accounts.services import get_account_balance
from apps.api.custom.pagination import CustomPageNumberPagination
from apps.api.serializers import AccountGroupSerializer, AccountSerializer, AccountBalanceSerializer
class AccountGroupViewSet(viewsets.ModelViewSet):
"""ViewSet for managing account groups."""
queryset = AccountGroup.objects.all()
serializer_class = AccountGroupSerializer
pagination_class = CustomPageNumberPagination
@@ -14,7 +21,16 @@ class AccountGroupViewSet(viewsets.ModelViewSet):
return AccountGroup.objects.all().order_by("id")
@extend_schema_view(
balance=extend_schema(
summary="Get account balance",
description="Returns the current and projected balance for the account, along with currency data.",
responses={200: AccountBalanceSerializer},
),
)
class AccountViewSet(viewsets.ModelViewSet):
"""ViewSet for managing accounts."""
queryset = Account.objects.all()
serializer_class = AccountSerializer
pagination_class = CustomPageNumberPagination
@@ -25,3 +41,20 @@ class AccountViewSet(viewsets.ModelViewSet):
.order_by("id")
.select_related("group", "currency", "exchange_currency")
)
@action(detail=True, methods=["get"], permission_classes=[IsAuthenticated])
def balance(self, request, pk=None):
"""Get current and projected balance for an account."""
account = self.get_object()
current_balance = get_account_balance(account, paid_only=True)
projected_balance = get_account_balance(account, paid_only=False)
serializer = AccountBalanceSerializer({
"current_balance": current_balance,
"projected_balance": projected_balance,
"currency": account.currency,
})
return Response(serializer.data)

View File

@@ -0,0 +1,123 @@
from django.core.files.storage import FileSystemStorage
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view, inline_serializer
from rest_framework import serializers as drf_serializers
from rest_framework import status, viewsets
from rest_framework.parsers import MultiPartParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from apps.api.serializers import ImportFileSerializer, ImportProfileSerializer, ImportRunSerializer
from apps.import_app.models import ImportProfile, ImportRun
from apps.import_app.tasks import process_import
@extend_schema_view(
list=extend_schema(
summary="List import profiles",
description="Returns a paginated list of all available import profiles.",
),
retrieve=extend_schema(
summary="Get import profile",
description="Returns the details of a specific import profile by ID.",
),
)
class ImportProfileViewSet(viewsets.ReadOnlyModelViewSet):
"""ViewSet for listing and retrieving import profiles."""
queryset = ImportProfile.objects.all()
serializer_class = ImportProfileSerializer
permission_classes = [IsAuthenticated]
@extend_schema_view(
list=extend_schema(
summary="List import runs",
description="Returns a paginated list of import runs. Optionally filter by profile_id.",
parameters=[
OpenApiParameter(
name="profile_id",
type=int,
location=OpenApiParameter.QUERY,
description="Filter runs by profile ID",
required=False,
),
],
),
retrieve=extend_schema(
summary="Get import run",
description="Returns the details of a specific import run by ID, including status and logs.",
),
)
class ImportRunViewSet(viewsets.ReadOnlyModelViewSet):
"""ViewSet for listing and retrieving import runs."""
queryset = ImportRun.objects.all().order_by("-id")
serializer_class = ImportRunSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
queryset = super().get_queryset()
profile_id = self.request.query_params.get("profile_id")
if profile_id:
queryset = queryset.filter(profile_id=profile_id)
return queryset
@extend_schema_view(
create=extend_schema(
summary="Import file",
description="Upload a CSV or XLSX file to import using an existing import profile. The import is queued and processed asynchronously.",
request={
"multipart/form-data": {
"type": "object",
"properties": {
"profile_id": {"type": "integer", "description": "ID of the ImportProfile to use"},
"file": {"type": "string", "format": "binary", "description": "CSV or XLSX file to import"},
},
"required": ["profile_id", "file"],
},
},
responses={
202: inline_serializer(
name="ImportResponse",
fields={
"import_run_id": drf_serializers.IntegerField(),
"status": drf_serializers.CharField(),
},
),
},
),
)
class ImportViewSet(viewsets.ViewSet):
"""ViewSet for importing data via file upload."""
permission_classes = [IsAuthenticated]
parser_classes = [MultiPartParser]
def create(self, request):
serializer = ImportFileSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
profile = serializer.validated_data["profile"]
uploaded_file = serializer.validated_data["file"]
# Save file to temp location
fs = FileSystemStorage(location="/usr/src/app/temp")
filename = fs.save(uploaded_file.name, uploaded_file)
file_path = fs.path(filename)
# Create ImportRun record
import_run = ImportRun.objects.create(profile=profile, file_name=filename)
# Queue import task
process_import.defer(
import_run_id=import_run.id,
file_path=file_path,
user_id=request.user.id,
)
return Response(
{"import_run_id": import_run.id, "status": "queued"},
status=status.HTTP_202_ACCEPTED,
)

View File

@@ -32,7 +32,7 @@ class TransactionViewSet(viewsets.ModelViewSet):
transaction_created.send(sender=instance)
def perform_update(self, serializer):
old_data = deepcopy(Transaction.objects.get(pk=serializer.data["pk"]))
old_data = deepcopy(self.get_object())
instance = serializer.save()
transaction_updated.send(sender=instance, old_data=old_data)

View File

@@ -475,11 +475,27 @@ class ImportService:
def _coerce_type(
self, value: str, mapping: version_1.ColumnMapping
) -> Union[str, int, bool, Decimal, datetime, list, None]:
coerce_to = mapping.coerce_to
# Handle detection methods that don't require a source value
if coerce_to == "transaction_type" and isinstance(
mapping, version_1.TransactionTypeMapping
):
if mapping.detection_method == "always_income":
return Transaction.Type.INCOME
elif mapping.detection_method == "always_expense":
return Transaction.Type.EXPENSE
elif coerce_to == "is_paid" and isinstance(
mapping, version_1.TransactionIsPaidMapping
):
if mapping.detection_method == "always_paid":
return True
elif mapping.detection_method == "always_unpaid":
return False
if not value:
return None
coerce_to = mapping.coerce_to
return self._coerce_single_type(value, coerce_to, mapping)
@staticmethod

View File

@@ -408,6 +408,10 @@ class Transaction(OwnedObject):
self.reference_date = self.date.replace(day=1)
def save(self, *args, **kwargs):
# This is here so Django validation doesn't trigger an error before clean() is ran
if not self.reference_date and self.date:
self.reference_date = self.date.replace(day=1)
# This is not recommended as it will run twice on some cases like form and API saves.
# We only do this here because we forgot to independently call it on multiple places.
self.full_clean()

View File

View File

@@ -1,9 +1,7 @@
import datetime
from decimal import Decimal
from datetime import date, timedelta
from django.test import TestCase
from django.core.exceptions import ValidationError
from django.utils import timezone
from apps.transactions.models import (

View File

@@ -0,0 +1,174 @@
from datetime import date
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from django.utils import timezone
from apps.accounts.models import Account, AccountGroup
from apps.currencies.models import Currency
from apps.transactions.models import (
Transaction,
TransactionCategory,
TransactionTag,
)
@override_settings(
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"},
},
WHITENOISE_AUTOREFRESH=True,
)
class TransactionSimpleAddViewTests(TestCase):
"""Tests for the transaction_simple_add view with query parameters"""
def setUp(self):
"""Set up test data"""
User = get_user_model()
self.user = User.objects.create_user(
email="testuser@test.com", password="testpass123"
)
self.client.login(username="testuser@test.com", password="testpass123")
self.currency = Currency.objects.create(
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
)
self.account_group = AccountGroup.objects.create(name="Test Group")
self.account = Account.objects.create(
name="Test Account", group=self.account_group, currency=self.currency
)
self.category = TransactionCategory.objects.create(name="Test Category")
self.tag = TransactionTag.objects.create(name="TestTag")
def test_get_returns_form_with_default_values(self):
"""Test GET request returns 200 and form with defaults"""
response = self.client.get("/add/")
self.assertEqual(response.status_code, 200)
self.assertIn("form", response.context)
def test_get_with_type_param(self):
"""Test type param sets form initial value"""
response = self.client.get("/add/?type=EX")
self.assertEqual(response.status_code, 200)
form = response.context["form"]
self.assertEqual(form.initial.get("type"), Transaction.Type.EXPENSE)
def test_get_with_account_param(self):
"""Test account param sets form initial value"""
response = self.client.get(f"/add/?account={self.account.id}")
self.assertEqual(response.status_code, 200)
form = response.context["form"]
self.assertEqual(form.initial.get("account"), self.account.id)
def test_get_with_is_paid_param_true(self):
"""Test is_paid param with true value"""
response = self.client.get("/add/?is_paid=true")
self.assertEqual(response.status_code, 200)
form = response.context["form"]
self.assertTrue(form.initial.get("is_paid"))
def test_get_with_is_paid_param_false(self):
"""Test is_paid param with false value"""
response = self.client.get("/add/?is_paid=false")
self.assertEqual(response.status_code, 200)
form = response.context["form"]
self.assertFalse(form.initial.get("is_paid"))
def test_get_with_amount_param(self):
"""Test amount param sets form initial value"""
response = self.client.get("/add/?amount=150.50")
self.assertEqual(response.status_code, 200)
form = response.context["form"]
self.assertEqual(form.initial.get("amount"), "150.50")
def test_get_with_description_param(self):
"""Test description param sets form initial value"""
response = self.client.get("/add/?description=Test%20Transaction")
self.assertEqual(response.status_code, 200)
form = response.context["form"]
self.assertEqual(form.initial.get("description"), "Test Transaction")
def test_get_with_notes_param(self):
"""Test notes param sets form initial value"""
response = self.client.get("/add/?notes=Some%20notes")
self.assertEqual(response.status_code, 200)
form = response.context["form"]
self.assertEqual(form.initial.get("notes"), "Some notes")
def test_get_with_category_param(self):
"""Test category param sets form initial value"""
response = self.client.get(f"/add/?category={self.category.id}")
self.assertEqual(response.status_code, 200)
form = response.context["form"]
self.assertEqual(form.initial.get("category"), self.category.id)
def test_get_with_tags_param(self):
"""Test tags param as comma-separated names"""
response = self.client.get("/add/?tags=TestTag,AnotherTag")
self.assertEqual(response.status_code, 200)
form = response.context["form"]
self.assertEqual(form.initial.get("tags"), ["TestTag", "AnotherTag"])
def test_get_with_all_params(self):
"""Test all params together work correctly"""
url = (
f"/add/?type=EX&account={self.account.id}&is_paid=true"
f"&amount=200.00&description=Full%20Test&notes=Test%20notes"
f"&category={self.category.id}&tags=TestTag"
)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
form = response.context["form"]
self.assertEqual(form.initial.get("type"), Transaction.Type.EXPENSE)
self.assertEqual(form.initial.get("account"), self.account.id)
self.assertTrue(form.initial.get("is_paid"))
self.assertEqual(form.initial.get("amount"), "200.00")
self.assertEqual(form.initial.get("description"), "Full Test")
self.assertEqual(form.initial.get("notes"), "Test notes")
self.assertEqual(form.initial.get("category"), self.category.id)
self.assertEqual(form.initial.get("tags"), ["TestTag"])
def test_post_creates_transaction(self):
"""Test form submission creates transaction"""
data = {
"account": self.account.id,
"type": "EX",
"is_paid": True,
"date": timezone.now().date().isoformat(),
"amount": "100.00",
"description": "Test Transaction",
}
response = self.client.post("/add/", data)
self.assertEqual(response.status_code, 200)
self.assertTrue(
Transaction.objects.filter(description="Test Transaction").exists()
)
def test_get_with_date_param(self):
"""Test date param overrides expected date"""
response = self.client.get("/add/?date=2025-06-15")
self.assertEqual(response.status_code, 200)
form = response.context["form"]
self.assertEqual(form.initial.get("date"), date(2025, 6, 15))
def test_get_with_reference_date_param(self):
"""Test reference_date param sets form initial value"""
response = self.client.get("/add/?reference_date=2025-07-01")
self.assertEqual(response.status_code, 200)
form = response.context["form"]
self.assertEqual(form.initial.get("reference_date"), date(2025, 7, 1))
def test_get_with_account_name_param(self):
"""Test account param by name (case-insensitive)"""
response = self.client.get("/add/?account=Test%20Account")
self.assertEqual(response.status_code, 200)
form = response.context["form"]
self.assertEqual(form.initial.get("account"), self.account.id)
def test_get_with_category_name_param(self):
"""Test category param by name (case-insensitive)"""
response = self.client.get("/add/?category=Test%20Category")
self.assertEqual(response.status_code, 200)
form = response.context["form"]
self.assertEqual(form.initial.get("category"), self.category.id)

View File

@@ -142,26 +142,95 @@ def transaction_simple_add(request):
year=year,
).date()
# Build initial data from query parameters
initial_data = {
"date": expected_date,
"type": transaction_type,
}
# Handle date param (ISO format: YYYY-MM-DD) - overrides expected_date
date_param = request.GET.get("date")
if date_param:
try:
initial_data["date"] = datetime.datetime.strptime(date_param, "%Y-%m-%d").date()
except ValueError:
pass
# Handle reference_date param (ISO format: YYYY-MM-DD)
reference_date_param = request.GET.get("reference_date")
if reference_date_param:
try:
initial_data["reference_date"] = datetime.datetime.strptime(reference_date_param, "%Y-%m-%d").date()
except ValueError:
pass
# Handle account param (by ID or name)
account_param = request.GET.get("account")
if account_param:
try:
initial_data["account"] = int(account_param)
except (ValueError, TypeError):
# Try to find by name
from apps.accounts.models import Account
account = Account.objects.filter(name__iexact=account_param, is_archived=False).first()
if account:
initial_data["account"] = account.pk
# Handle is_paid param (boolean)
is_paid = request.GET.get("is_paid")
if is_paid is not None:
initial_data["is_paid"] = is_paid.lower() in ("true", "1", "yes")
# Handle amount param (decimal)
amount = request.GET.get("amount")
if amount:
try:
initial_data["amount"] = amount
except (ValueError, TypeError):
pass
# Handle description param (string)
description = request.GET.get("description")
if description:
initial_data["description"] = description
# Handle notes param (string)
notes = request.GET.get("notes")
if notes:
initial_data["notes"] = notes
# Handle category param (by ID or name)
category_param = request.GET.get("category")
if category_param:
try:
initial_data["category"] = int(category_param)
except (ValueError, TypeError):
# Try to find by name
from apps.transactions.models import TransactionCategory
category = TransactionCategory.objects.filter(name__iexact=category_param, active=True).first()
if category:
initial_data["category"] = category.pk
# Handle tags param (comma-separated names)
tags = request.GET.get("tags")
if tags:
initial_data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
# Handle entities param (comma-separated names)
entities = request.GET.get("entities")
if entities:
initial_data["entities"] = [e.strip() for e in entities.split(",") if e.strip()]
if request.method == "POST":
form = TransactionForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, _("Transaction added successfully"))
form = TransactionForm(
initial={
"date": expected_date,
"type": transaction_type,
},
)
# Only reset form after successful save
form = TransactionForm(initial=initial_data)
else:
form = TransactionForm(
initial={
"date": expected_date,
"type": transaction_type,
},
)
form = TransactionForm(initial=initial_data)
return render(
request,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -29,7 +29,7 @@ export default defineConfig({
server: {
host: '0.0.0.0',
port: 5173,
port: parseInt(process.env.VITE_DEV_SERVER_PORT || '5173'),
open: false,
watch: {
usePolling: true,
@@ -37,7 +37,7 @@ export default defineConfig({
},
hmr: false,
cors: true,
origin: 'http://localhost:5173'
origin: `http://${process.env.VITE_DEV_SERVER_HOST || 'localhost'}:${process.env.VITE_DEV_SERVER_PORT || '5173'}`
},
resolve: {

View File

@@ -1,32 +1,32 @@
Django~=5.2
psycopg[binary]==3.2.9
Django~=5.2.9
psycopg[binary,pool]==3.2.9
django-vite==3.1.0
django-crispy-forms==2.4
django-crispy-forms==2.5
crispy-bootstrap5==2025.6
django-browser-reload==1.18.0
django-hijack==3.7.3
django-filter==25.1
django-debug-toolbar==4.4.6
django-browser-reload==1.21.0
django-hijack==3.7.4
django-filter==25.2
django-debug-toolbar==6.1.0
django-cachalot~=2.8.0
django-cotton~=2.1.3
django-cotton<2.3.0
django-pwa~=2.0.1
djangorestframework~=3.16.0
drf-spectacular~=0.28.0
drf-spectacular~=0.29.0
django-import-export~=4.3.9
gunicorn==23.0.0
whitenoise[brotli]==6.9.0
whitenoise[brotli]==6.11.0
watchfiles==1.1.0 # https://github.com/samuelcolvin/watchfiles
procrastinate[django]~=3.4.0
watchfiles==1.1.1
procrastinate[django]~=3.5.3
requests~=2.32.3
django-allauth[socialaccount]~=65.10.0
requests~=2.32.5
django-allauth[socialaccount]~=65.13.1
pytz
python-dateutil~=2.9.0.post0
simpleeval~=1.0.3
pydantic~=2.11.3
pydantic~=2.12.3
PyYAML~=6.0.2
mistune~=3.1.3
openpyxl~=3.1.5