feat: replace action row with a FAB

This commit is contained in:
Herculino Trotta
2025-06-15 23:12:22 -03:00
parent 02f6bb0c29
commit 1d3dc3f5a2
9 changed files with 1358 additions and 501 deletions
+221 -69
View File
@@ -13,54 +13,88 @@ from django.urls import reverse
from apps.import_app.models import ImportProfile, ImportRun
from apps.import_app.services.v1 import ImportService
from apps.import_app.schemas.v1 import ImportProfileSchema, CSVImportSettings, ColumnMapping, TransactionDateMapping, TransactionAmountMapping, TransactionDescriptionMapping, TransactionAccountMapping
from apps.import_app.schemas.v1 import (
ImportProfileSchema,
CSVImportSettings,
ColumnMapping,
TransactionDateMapping,
TransactionAmountMapping,
TransactionDescriptionMapping,
TransactionAccountMapping,
)
from apps.accounts.models import Account
from apps.currencies.models import Currency
from apps.transactions.models import Transaction, TransactionCategory, TransactionTag, TransactionEntity
from apps.transactions.models import (
Transaction,
TransactionCategory,
TransactionTag,
TransactionEntity,
)
# Mocking get_current_user from thread_local
from apps.common.middleware.thread_local import get_current_user, set_current_user
from apps.common.middleware.thread_local import get_current_user, write_current_user
User = get_user_model()
# --- Base Test Case ---
class BaseImportAppTest(TestCase):
def setUp(self):
self.user = User.objects.create_user(email="importer@example.com", password="password")
set_current_user(self.user) # For services that rely on get_current_user
self.user = User.objects.create_user(
email="importer@example.com", password="password"
)
write_current_user(self.user) # For services that rely on get_current_user
self.client = Client()
self.client.login(email="importer@example.com", password="password")
self.currency_usd = Currency.objects.create(code="USD", name="US Dollar")
self.account_usd = Account.objects.create(name="Checking USD", currency=self.currency_usd, owner=self.user)
self.account_usd = Account.objects.create(
name="Checking USD", currency=self.currency_usd, owner=self.user
)
def tearDown(self):
set_current_user(None)
write_current_user(None)
def _create_valid_transaction_import_profile_yaml(self, extra_settings=None, extra_mappings=None):
def _create_valid_transaction_import_profile_yaml(
self, extra_settings=None, extra_mappings=None
):
settings_dict = {
"file_type": "csv",
"delimiter": ",",
"skip_lines": 0,
"importing": "transactions",
"trigger_transaction_rules": False,
**(extra_settings or {})
**(extra_settings or {}),
}
mappings_dict = {
"col_date": {"target": "date", "source": "DateColumn", "format": "%Y-%m-%d"},
"col_date": {
"target": "date",
"source": "DateColumn",
"format": "%Y-%m-%d",
},
"col_amount": {"target": "amount", "source": "AmountColumn"},
"col_desc": {"target": "description", "source": "DescriptionColumn"},
"col_acc": {"target": "account", "source": "AccountNameColumn", "type": "name"},
**(extra_mappings or {})
"col_acc": {
"target": "account",
"source": "AccountNameColumn",
"type": "name",
},
**(extra_mappings or {}),
}
return yaml.dump({"settings": settings_dict, "mapping": mappings_dict})
# --- Model Tests ---
class ImportProfileModelTests(BaseImportAppTest):
def test_import_profile_valid_yaml_clean(self):
valid_yaml = self._create_valid_transaction_import_profile_yaml()
profile = ImportProfile(name="Test Valid Profile", yaml_config=valid_yaml, version=ImportProfile.Versions.VERSION_1)
profile = ImportProfile(
name="Test Valid Profile",
yaml_config=valid_yaml,
version=ImportProfile.Versions.VERSION_1,
)
try:
profile.full_clean() # Should not raise ValidationError
except ValidationError as e:
@@ -77,13 +111,20 @@ settings:
mapping:
col_date: {target: date, source: Date, format: "%Y-%m-%d"}
"""
profile = ImportProfile(name="Test Invalid Profile", yaml_config=invalid_yaml, version=ImportProfile.Versions.VERSION_1)
profile = ImportProfile(
name="Test Invalid Profile",
yaml_config=invalid_yaml,
version=ImportProfile.Versions.VERSION_1,
)
with self.assertRaises(ValidationError) as context:
profile.full_clean()
self.assertIn("yaml_config", context.exception.message_dict)
self.assertTrue("Input should be a valid string" in str(context.exception.message_dict["yaml_config"]) or \
"Input should be a valid integer" in str(context.exception.message_dict["yaml_config"]))
self.assertTrue(
"Input should be a valid string"
in str(context.exception.message_dict["yaml_config"])
or "Input should be a valid integer"
in str(context.exception.message_dict["yaml_config"])
)
def test_import_profile_invalid_mapping_for_import_type(self):
invalid_yaml = """
@@ -93,11 +134,18 @@ settings:
mapping:
some_col: {target: account_name, source: SomeColumn}
"""
profile = ImportProfile(name="Invalid Mapping Type", yaml_config=invalid_yaml, version=ImportProfile.Versions.VERSION_1)
profile = ImportProfile(
name="Invalid Mapping Type",
yaml_config=invalid_yaml,
version=ImportProfile.Versions.VERSION_1,
)
with self.assertRaises(ValidationError) as context:
profile.full_clean()
self.assertIn("yaml_config", context.exception.message_dict)
self.assertIn("Mapping type 'AccountNameMapping' is not allowed when importing tags", str(context.exception.message_dict["yaml_config"]))
self.assertIn(
"Mapping type 'AccountNameMapping' is not allowed when importing tags",
str(context.exception.message_dict["yaml_config"]),
)
# --- Service Tests (Focus on ImportService v1) ---
@@ -105,8 +153,12 @@ class ImportServiceV1LogicTests(BaseImportAppTest):
def setUp(self):
super().setUp()
self.basic_yaml_config = self._create_valid_transaction_import_profile_yaml()
self.profile = ImportProfile.objects.create(name="Service Test Profile", yaml_config=self.basic_yaml_config)
self.import_run = ImportRun.objects.create(profile=self.profile, file_name="test.csv")
self.profile = ImportProfile.objects.create(
name="Service Test Profile", yaml_config=self.basic_yaml_config
)
self.import_run = ImportRun.objects.create(
profile=self.profile, file_name="test.csv"
)
def get_service(self):
self.import_run.logs = ""
@@ -116,41 +168,77 @@ class ImportServiceV1LogicTests(BaseImportAppTest):
def test_transform_value_replace(self):
service = self.get_service()
mapping_def = {"type": "replace", "pattern": "USD", "replacement": "EUR"}
mapping = ColumnMapping(source="col", target="field", transformations=[mapping_def])
self.assertEqual(service._transform_value("Amount USD", mapping, row={"col":"Amount USD"}), "Amount EUR")
mapping = ColumnMapping(
source="col", target="field", transformations=[mapping_def]
)
self.assertEqual(
service._transform_value("Amount USD", mapping, row={"col": "Amount USD"}),
"Amount EUR",
)
def test_transform_value_regex(self):
service = self.get_service()
mapping_def = {"type": "regex", "pattern": r"\d+", "replacement": "NUM"}
mapping = ColumnMapping(source="col", target="field", transformations=[mapping_def])
self.assertEqual(service._transform_value("abc123xyz", mapping, row={"col":"abc123xyz"}), "abcNUMxyz")
mapping = ColumnMapping(
source="col", target="field", transformations=[mapping_def]
)
self.assertEqual(
service._transform_value("abc123xyz", mapping, row={"col": "abc123xyz"}),
"abcNUMxyz",
)
def test_transform_value_date_format(self):
service = self.get_service()
mapping_def = {"type": "date_format", "original_format": "%d/%m/%Y", "new_format": "%Y-%m-%d"}
mapping = ColumnMapping(source="col", target="field", transformations=[mapping_def])
self.assertEqual(service._transform_value("15/10/2023", mapping, row={"col":"15/10/2023"}), "2023-10-15")
mapping_def = {
"type": "date_format",
"original_format": "%d/%m/%Y",
"new_format": "%Y-%m-%d",
}
mapping = ColumnMapping(
source="col", target="field", transformations=[mapping_def]
)
self.assertEqual(
service._transform_value("15/10/2023", mapping, row={"col": "15/10/2023"}),
"2023-10-15",
)
def test_transform_value_merge(self):
service = self.get_service()
mapping_def = {"type": "merge", "fields": ["colA", "colB"], "separator": "-"}
mapping = ColumnMapping(source="colA", target="field", transformations=[mapping_def])
mapping = ColumnMapping(
source="colA", target="field", transformations=[mapping_def]
)
row_data = {"colA": "ValA", "colB": "ValB"}
self.assertEqual(service._transform_value(row_data["colA"], mapping, row_data), "ValA-ValB")
self.assertEqual(
service._transform_value(row_data["colA"], mapping, row_data), "ValA-ValB"
)
def test_transform_value_split(self):
service = self.get_service()
mapping_def = {"type": "split", "separator": "|", "index": 1}
mapping = ColumnMapping(source="col", target="field", transformations=[mapping_def])
self.assertEqual(service._transform_value("partA|partB|partC", mapping, row={"col":"partA|partB|partC"}), "partB")
mapping = ColumnMapping(
source="col", target="field", transformations=[mapping_def]
)
self.assertEqual(
service._transform_value(
"partA|partB|partC", mapping, row={"col": "partA|partB|partC"}
),
"partB",
)
def test_coerce_type_date(self):
service = self.get_service()
mapping = TransactionDateMapping(source="col", target="date", format="%Y-%m-%d")
self.assertEqual(service._coerce_type("2023-11-21", mapping), date(2023, 11, 21))
self.assertEqual(
service._coerce_type("2023-11-21", mapping), date(2023, 11, 21)
)
mapping_multi_format = TransactionDateMapping(source="col", target="date", format=["%d/%m/%Y", "%Y-%m-%d"])
self.assertEqual(service._coerce_type("21/11/2023", mapping_multi_format), date(2023, 11, 21))
mapping_multi_format = TransactionDateMapping(
source="col", target="date", format=["%d/%m/%Y", "%Y-%m-%d"]
)
self.assertEqual(
service._coerce_type("21/11/2023", mapping_multi_format), date(2023, 11, 21)
)
def test_coerce_type_decimal(self):
service = self.get_service()
@@ -168,8 +256,13 @@ class ImportServiceV1LogicTests(BaseImportAppTest):
def test_map_row_simple(self):
service = self.get_service()
row = {"DateColumn": "2023-01-15", "AmountColumn": "100.50", "DescriptionColumn": "Lunch", "AccountNameColumn": "Checking USD"}
with patch.object(Account.objects, 'filter') as mock_filter:
row = {
"DateColumn": "2023-01-15",
"AmountColumn": "100.50",
"DescriptionColumn": "Lunch",
"AccountNameColumn": "Checking USD",
}
with patch.object(Account.objects, "filter") as mock_filter:
mock_filter.return_value.first.return_value = self.account_usd
mapped = service._map_row(row)
self.assertEqual(mapped["date"], date(2023, 1, 15))
@@ -178,46 +271,82 @@ class ImportServiceV1LogicTests(BaseImportAppTest):
self.assertEqual(mapped["account"], self.account_usd)
def test_check_duplicate_transaction_strict(self):
dedup_yaml = yaml.dump({
"settings": {"file_type": "csv", "importing": "transactions"},
"mapping": {
"col_date": {"target": "date", "source": "Date", "format": "%Y-%m-%d"},
"col_amount": {"target": "amount", "source": "Amount"},
"col_desc": {"target": "description", "source": "Desc"},
"col_acc": {"target": "account", "source": "Acc", "type": "name"},
},
"deduplication": [{"type": "compare", "fields": ["date", "amount", "description", "account"], "match_type": "strict"}]
})
profile = ImportProfile.objects.create(name="Dedupe Profile Strict", yaml_config=dedup_yaml)
dedup_yaml = yaml.dump(
{
"settings": {"file_type": "csv", "importing": "transactions"},
"mapping": {
"col_date": {
"target": "date",
"source": "Date",
"format": "%Y-%m-%d",
},
"col_amount": {"target": "amount", "source": "Amount"},
"col_desc": {"target": "description", "source": "Desc"},
"col_acc": {"target": "account", "source": "Acc", "type": "name"},
},
"deduplication": [
{
"type": "compare",
"fields": ["date", "amount", "description", "account"],
"match_type": "strict",
}
],
}
)
profile = ImportProfile.objects.create(
name="Dedupe Profile Strict", yaml_config=dedup_yaml
)
import_run = ImportRun.objects.create(profile=profile, file_name="dedupe.csv")
service = ImportService(import_run)
Transaction.objects.create(
owner=self.user, account=self.account_usd, date=date(2023,1,1), amount=Decimal("10.00"), description="Coffee"
owner=self.user,
account=self.account_usd,
date=date(2023, 1, 1),
amount=Decimal("10.00"),
description="Coffee",
)
dup_data = {"owner": self.user, "account": self.account_usd, "date": date(2023,1,1), "amount": Decimal("10.00"), "description": "Coffee"}
dup_data = {
"owner": self.user,
"account": self.account_usd,
"date": date(2023, 1, 1),
"amount": Decimal("10.00"),
"description": "Coffee",
}
self.assertTrue(service._check_duplicate_transaction(dup_data))
not_dup_data = {"owner": self.user, "account": self.account_usd, "date": date(2023,1,1), "amount": Decimal("10.00"), "description": "Tea"}
not_dup_data = {
"owner": self.user,
"account": self.account_usd,
"date": date(2023, 1, 1),
"amount": Decimal("10.00"),
"description": "Tea",
}
self.assertFalse(service._check_duplicate_transaction(not_dup_data))
class ImportServiceFileProcessingTests(BaseImportAppTest):
@patch('apps.import_app.tasks.process_import.defer')
@patch("apps.import_app.tasks.process_import.defer")
def test_process_csv_file_basic_transaction_import(self, mock_defer):
csv_content = "DateColumn,AmountColumn,DescriptionColumn,AccountNameColumn\n2023-03-10,123.45,Test CSV Import 1,Checking USD\n2023-03-11,67.89,Test CSV Import 2,Checking USD"
profile_yaml = self._create_valid_transaction_import_profile_yaml()
profile = ImportProfile.objects.create(name="CSV Test Profile", yaml_config=profile_yaml)
profile = ImportProfile.objects.create(
name="CSV Test Profile", yaml_config=profile_yaml
)
with tempfile.NamedTemporaryFile(mode="w+", delete=False, suffix=".csv", dir=ImportService.TEMP_DIR) as tmp_file:
with tempfile.NamedTemporaryFile(
mode="w+", delete=False, suffix=".csv", dir=ImportService.TEMP_DIR
) as tmp_file:
tmp_file.write(csv_content)
tmp_file_path = tmp_file.name
import_run = ImportRun.objects.create(profile=profile, file_name=os.path.basename(tmp_file_path))
import_run = ImportRun.objects.create(
profile=profile, file_name=os.path.basename(tmp_file_path)
)
service = ImportService(import_run)
with patch.object(Account.objects, 'filter') as mock_account_filter:
with patch.object(Account.objects, "filter") as mock_account_filter:
mock_account_filter.return_value.first.return_value = self.account_usd
service.process_file(tmp_file_path)
@@ -234,9 +363,13 @@ class ImportServiceFileProcessingTests(BaseImportAppTest):
if os.path.exists(tmp_file_path):
os.remove(tmp_file_path)
class ImportViewTests(BaseImportAppTest):
def test_import_profile_list_view(self):
ImportProfile.objects.create(name="Profile 1", yaml_config=self._create_valid_transaction_import_profile_yaml())
ImportProfile.objects.create(
name="Profile 1",
yaml_config=self._create_valid_transaction_import_profile_yaml(),
)
response = self.client.get(reverse("import_profile_list"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Profile 1")
@@ -244,18 +377,29 @@ class ImportViewTests(BaseImportAppTest):
def test_import_profile_add_view_get(self):
response = self.client.get(reverse("import_profile_add"))
self.assertEqual(response.status_code, 200)
self.assertIsInstance(response.context['form'], ImportProfileForm)
self.assertIsInstance(response.context["form"], ImportProfileForm)
@patch('apps.import_app.tasks.process_import.defer')
@patch("apps.import_app.tasks.process_import.defer")
def test_import_run_add_view_post_valid_file(self, mock_defer):
profile = ImportProfile.objects.create(name="Upload Profile", yaml_config=self._create_valid_transaction_import_profile_yaml())
profile = ImportProfile.objects.create(
name="Upload Profile",
yaml_config=self._create_valid_transaction_import_profile_yaml(),
)
csv_content = "DateColumn,AmountColumn,DescriptionColumn,AccountNameColumn\n2023-01-01,10.00,Test Upload,Checking USD"
uploaded_file = SimpleUploadedFile("test_upload.csv", csv_content.encode('utf-8'), content_type="text/csv")
uploaded_file = SimpleUploadedFile(
"test_upload.csv", csv_content.encode("utf-8"), content_type="text/csv"
)
response = self.client.post(reverse("import_run_add", args=[profile.id]), {"file": uploaded_file})
response = self.client.post(
reverse("import_run_add", args=[profile.id]), {"file": uploaded_file}
)
self.assertEqual(response.status_code, 204)
self.assertTrue(ImportRun.objects.filter(profile=profile, file_name__contains="test_upload.csv").exists())
self.assertTrue(
ImportRun.objects.filter(
profile=profile, file_name__contains="test_upload.csv"
).exists()
)
mock_defer.assert_called_once()
args_list = mock_defer.call_args_list[0]
kwargs_passed = args_list.kwargs
@@ -263,9 +407,17 @@ class ImportViewTests(BaseImportAppTest):
self.assertIn("file_path", kwargs_passed)
self.assertEqual(kwargs_passed["user_id"], self.user.id)
run = ImportRun.objects.get(profile=profile, file_name__contains="test_upload.csv")
temp_file_path_in_storage = os.path.join(ImportService.TEMP_DIR, run.file_name) # Ensure correct path construction
if os.path.exists(temp_file_path_in_storage): # Check existence before removing
os.remove(temp_file_path_in_storage)
elif os.path.exists(os.path.join(ImportService.TEMP_DIR, os.path.basename(run.file_name))): # Fallback for just basename
os.remove(os.path.join(ImportService.TEMP_DIR, os.path.basename(run.file_name)))
run = ImportRun.objects.get(
profile=profile, file_name__contains="test_upload.csv"
)
temp_file_path_in_storage = os.path.join(
ImportService.TEMP_DIR, run.file_name
) # Ensure correct path construction
if os.path.exists(temp_file_path_in_storage): # Check existence before removing
os.remove(temp_file_path_in_storage)
elif os.path.exists(
os.path.join(ImportService.TEMP_DIR, os.path.basename(run.file_name))
): # Fallback for just basename
os.remove(
os.path.join(ImportService.TEMP_DIR, os.path.basename(run.file_name))
)