mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-11-13 12:23:44 +01:00
feat(import): pick the account when running an import
An import run can now be given an account, and every transaction it creates goes into that account instead of the one the profile maps. This lets a single profile serve several accounts at the same bank, for statements that only name the account in a header cell or not at all, instead of keeping one copy of the profile per account. - ImportRun gets an optional `account` (SET_NULL), shown on the run card. - The upload form offers the user's active accounts when the profile imports transactions, and leaves the field out for profiles that import accounts, currencies, categories, tags or entities. - CSV and Excel rows take the run's account before deduplication, so rules comparing `account` check against the picked account. The profile's account mapping is skipped for these rows. - QIF files use the run's account instead of the account named after the file. - The import API accepts an optional `account_id`, limited to accounts the user can access, rejected for profiles that don't import transactions, and returns `account` on import runs. Leaving the account empty keeps the current behaviour. Closes #538
This commit is contained in:
1 parent
6c3c4c03ec
commit
1ed5dc5d31
9 files changed
+497
-11
No files matched your search
@@ -1,5 +1,7 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.accounts.models import Account
|
||||
from apps.import_app.models import ImportProfile, ImportRun
|
||||
|
||||
|
||||
@@ -21,6 +23,7 @@ class ImportRunSerializer(serializers.ModelSerializer):
|
||||
"status",
|
||||
"profile",
|
||||
"file_name",
|
||||
"account",
|
||||
"logs",
|
||||
"processed_rows",
|
||||
"total_rows",
|
||||
@@ -39,8 +42,22 @@ class ImportFileSerializer(serializers.Serializer):
|
||||
queryset=ImportProfile.objects.all(), source="profile"
|
||||
)
|
||||
file = serializers.FileField()
|
||||
account_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Account.objects.all(),
|
||||
source="account",
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Reload the queryset to get an updated version with the requesting user
|
||||
# Reload the querysets to get an updated version with the requesting user
|
||||
self.fields["profile_id"].queryset = ImportProfile.objects.all()
|
||||
self.fields["account_id"].queryset = Account.objects.all()
|
||||
|
||||
def validate(self, data):
|
||||
if data.get("account") and not data["profile"].imports_transactions:
|
||||
raise serializers.ValidationError(
|
||||
{"account_id": _("Only transaction imports can use an account.")}
|
||||
)
|
||||
return data
|
||||
@@ -102,6 +102,7 @@ class ImportRunViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"properties": {
|
||||
"profile_id": {"type": "integer", "description": "ID of the ImportProfile to use"},
|
||||
"file": {"type": "string", "format": "binary", "description": "CSV or XLSX file to import"},
|
||||
"account_id": {"type": "integer", "description": "ID of the Account to import every transaction into. Leave empty to let the profile pick the account. Only for profiles that import transactions"},
|
||||
},
|
||||
"required": ["profile_id", "file"],
|
||||
},
|
||||
@@ -137,7 +138,10 @@ class ImportViewSet(viewsets.ViewSet):
|
||||
|
||||
# Create ImportRun record
|
||||
import_run = ImportRun.objects.create(
|
||||
profile=profile, file_name=filename, owner=request.user
|
||||
profile=profile,
|
||||
file_name=filename,
|
||||
account=serializer.validated_data.get("account"),
|
||||
owner=request.user,
|
||||
)
|
||||
|
||||
# Queue import task
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from apps.accounts.models import Account
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from apps.common.widgets.tom_select import TomSelect
|
||||
from apps.import_app.models import ImportProfile
|
||||
from crispy_forms.bootstrap import FormActions
|
||||
from crispy_forms.helper import FormHelper
|
||||
@@ -42,15 +44,33 @@ class ImportProfileForm(forms.ModelForm):
|
||||
|
||||
class ImportRunFileUploadForm(forms.Form):
|
||||
file = forms.FileField(label=_("Select a file"))
|
||||
account = forms.ModelChoiceField(
|
||||
queryset=Account.objects.filter(is_archived=False),
|
||||
required=False,
|
||||
label=_("Account"),
|
||||
help_text=_(
|
||||
"Import every transaction into this account. Leave empty to let the "
|
||||
"profile pick the account."
|
||||
),
|
||||
widget=TomSelect(group_by="group"),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
def __init__(self, *args, profile, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.form_method = "post"
|
||||
self.helper.layout = Layout(
|
||||
"file",
|
||||
self.helper.layout = Layout("file")
|
||||
|
||||
if profile.imports_transactions:
|
||||
self.fields["account"].queryset = Account.objects.filter(is_archived=False)
|
||||
self.helper.layout.append("account")
|
||||
else:
|
||||
# Only transactions belong to an account
|
||||
del self.fields["account"]
|
||||
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit("submit", _("Import"), css_class="btn btn-primary"),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.2.17 on 2026-11-11 17:04
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0022_account_show_exchanged_amounts'),
|
||||
('import_app', '0003_owner_and_sharing'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='importrun',
|
||||
name='account',
|
||||
field=models.ForeignKey(blank=True, help_text='Import every transaction into this account. Leave empty to let the profile pick the account.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='import_runs', to='accounts.account', verbose_name='Account'),
|
||||
),
|
||||
]
|
||||
@@ -38,6 +38,15 @@ class ImportProfile(SharedObject):
|
||||
version_number = self.Versions(self.version).name.split("_")[1]
|
||||
return _("Version {number}").format(number=version_number)
|
||||
|
||||
@property
|
||||
def imports_transactions(self):
|
||||
"""Whether this profile creates transactions, as opposed to accounts, tags, etc."""
|
||||
try:
|
||||
config = version_1.ImportProfileSchema(**yaml.safe_load(self.yaml_config))
|
||||
except Exception:
|
||||
return False
|
||||
return config.settings.importing == "transactions"
|
||||
|
||||
def clean(self):
|
||||
if self.version and self.version == self.Versions.VERSION_1:
|
||||
try:
|
||||
@@ -70,6 +79,18 @@ class ImportRun(OwnedObject):
|
||||
max_length=10000,
|
||||
help_text=_("File name"),
|
||||
)
|
||||
account = models.ForeignKey(
|
||||
"accounts.Account",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="import_runs",
|
||||
verbose_name=_("Account"),
|
||||
help_text=_(
|
||||
"Import every transaction into this account. Leave empty to let the "
|
||||
"profile pick the account."
|
||||
),
|
||||
)
|
||||
transactions = models.ManyToManyField(
|
||||
"transactions.Transaction", related_name="import_runs"
|
||||
)
|
||||
|
||||
@@ -289,7 +289,8 @@ class ImportService:
|
||||
# Ignore if category doesn't exist and create is False or not set
|
||||
data["category"] = None
|
||||
|
||||
if "account" in data:
|
||||
# Look the account up, unless the run already picked one
|
||||
if "account" in data and not isinstance(data["account"], Account):
|
||||
account_id = data.pop("account")
|
||||
account_mapping = next(
|
||||
(
|
||||
@@ -710,6 +711,10 @@ class ImportService:
|
||||
if mapped_data:
|
||||
# Handle different import types
|
||||
if self.settings.importing == "transactions":
|
||||
if self.import_run.account:
|
||||
# The run's account wins over the profile's account mapping
|
||||
mapped_data["account"] = self.import_run.account
|
||||
|
||||
if self.deduplication and self._check_duplicate_transaction(
|
||||
mapped_data
|
||||
):
|
||||
@@ -920,7 +925,9 @@ class ImportService:
|
||||
current_transaction = {}
|
||||
raw_lines_buffer = []
|
||||
|
||||
account = Account.objects.filter(name=account_name).first()
|
||||
account = (
|
||||
self.import_run.account or Account.objects.filter(name=account_name).first()
|
||||
)
|
||||
if not account:
|
||||
raise ValueError(f"Account '{account_name}' not found.")
|
||||
|
||||
@@ -1149,6 +1156,11 @@ class ImportService:
|
||||
self.import_run.save(update_fields=["started_at"])
|
||||
|
||||
self._log("info", "Starting import process")
|
||||
if self.import_run.account:
|
||||
self._log(
|
||||
"info",
|
||||
f"Importing transactions into account '{self.import_run.account.name}'",
|
||||
)
|
||||
|
||||
try:
|
||||
if isinstance(self.settings, version_1.CSVImportSettings):
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
"""Tests for importing a file into an account picked for the import run."""
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
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 django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.accounts.models import Account
|
||||
from apps.common.middleware.thread_local import delete_current_user, write_current_user
|
||||
from apps.currencies.models import Currency
|
||||
from apps.import_app.models import ImportProfile, ImportRun
|
||||
from apps.import_app.services.v1 import ImportService
|
||||
from apps.import_app.tests.test_view_permissions import (
|
||||
HTMX,
|
||||
ImportObjectPermissionTestCase,
|
||||
)
|
||||
from apps.transactions.models import Transaction
|
||||
|
||||
CSV_SETTINGS = """
|
||||
settings:
|
||||
file_type: csv
|
||||
importing: transactions
|
||||
trigger_transaction_rules: false
|
||||
"""
|
||||
|
||||
CSV_MAPPING = """
|
||||
mapping:
|
||||
date:
|
||||
target: date
|
||||
source: date
|
||||
format: "%Y-%m-%d"
|
||||
amount:
|
||||
target: amount
|
||||
source: amount
|
||||
type:
|
||||
target: type
|
||||
source: amount
|
||||
detection_method: sign
|
||||
description:
|
||||
target: description
|
||||
source: description
|
||||
"""
|
||||
|
||||
CSV_ACCOUNT_MAPPING = """
|
||||
account:
|
||||
target: account
|
||||
default: Checking
|
||||
"""
|
||||
|
||||
CSV_ACCOUNT_DEDUPLICATION = """
|
||||
deduplication:
|
||||
- type: compare
|
||||
fields:
|
||||
- date
|
||||
- amount
|
||||
- account
|
||||
match_type: strict
|
||||
"""
|
||||
|
||||
CSV_CONTENT = """date,amount,description
|
||||
2026-01-05,-50.00,Groceries
|
||||
2026-01-06,1000.00,Salary
|
||||
"""
|
||||
|
||||
TAGS_YAML_CONFIG = """
|
||||
settings:
|
||||
file_type: csv
|
||||
importing: tags
|
||||
mapping:
|
||||
name:
|
||||
target: tag_name
|
||||
source: name
|
||||
"""
|
||||
|
||||
|
||||
class ImportRunAccountServiceTests(TestCase):
|
||||
def setUp(self):
|
||||
self.temp_root = Path(tempfile.mkdtemp()).resolve()
|
||||
self.addCleanup(shutil.rmtree, self.temp_root, ignore_errors=True)
|
||||
temp_root_override = override_settings(IMPORT_TEMP_ROOT=self.temp_root)
|
||||
temp_root_override.enable()
|
||||
self.addCleanup(temp_root_override.disable)
|
||||
|
||||
self.user = get_user_model().objects.create_user(
|
||||
email="test@example.com", password="password"
|
||||
)
|
||||
write_current_user(self.user)
|
||||
self.addCleanup(delete_current_user)
|
||||
|
||||
currency = Currency.objects.create(
|
||||
code="EUR", name="Euro", decimal_places=2, prefix="€ "
|
||||
)
|
||||
self.checking = Account.objects.create(
|
||||
name="Checking", currency=currency, owner=self.user
|
||||
)
|
||||
self.savings = Account.objects.create(
|
||||
name="Savings", currency=currency, owner=self.user
|
||||
)
|
||||
|
||||
def _import(self, yaml_config, file_name, content, account=None):
|
||||
profile = ImportProfile.objects.create(
|
||||
name="Profile", yaml_config=yaml_config, owner=self.user
|
||||
)
|
||||
run = ImportRun.objects.create(
|
||||
profile=profile, file_name=file_name, account=account, owner=self.user
|
||||
)
|
||||
file_path = self.temp_root / file_name
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
|
||||
ImportService(run).process_file(str(file_path))
|
||||
|
||||
run.refresh_from_db()
|
||||
return run
|
||||
|
||||
def test_run_account_overrides_the_profile_account(self):
|
||||
run = self._import(
|
||||
CSV_SETTINGS + CSV_MAPPING + CSV_ACCOUNT_MAPPING,
|
||||
"statement.csv",
|
||||
CSV_CONTENT,
|
||||
account=self.savings,
|
||||
)
|
||||
|
||||
self.assertEqual(run.status, ImportRun.Status.FINISHED)
|
||||
self.assertEqual(run.successful_rows, 2)
|
||||
self.assertEqual(Transaction.objects.filter(account=self.savings).count(), 2)
|
||||
self.assertFalse(Transaction.objects.filter(account=self.checking).exists())
|
||||
self.assertIn("Importing transactions into account 'Savings'", run.logs)
|
||||
|
||||
def test_profile_account_is_used_without_a_run_account(self):
|
||||
run = self._import(
|
||||
CSV_SETTINGS + CSV_MAPPING + CSV_ACCOUNT_MAPPING,
|
||||
"statement.csv",
|
||||
CSV_CONTENT,
|
||||
)
|
||||
|
||||
self.assertEqual(run.successful_rows, 2)
|
||||
self.assertEqual(Transaction.objects.filter(account=self.checking).count(), 2)
|
||||
|
||||
def test_run_account_imports_with_a_profile_without_account_mapping(self):
|
||||
run = self._import(
|
||||
CSV_SETTINGS + CSV_MAPPING,
|
||||
"statement.csv",
|
||||
CSV_CONTENT,
|
||||
account=self.checking,
|
||||
)
|
||||
|
||||
self.assertEqual(run.successful_rows, 2)
|
||||
expense = Transaction.objects.get(description="Groceries")
|
||||
self.assertEqual(expense.account, self.checking)
|
||||
self.assertEqual(expense.type, Transaction.Type.EXPENSE)
|
||||
self.assertEqual(expense.amount, Decimal("50.00"))
|
||||
|
||||
def test_deduplication_by_account_uses_the_run_account(self):
|
||||
yaml_config = CSV_SETTINGS + CSV_MAPPING + CSV_ACCOUNT_DEDUPLICATION
|
||||
|
||||
self._import(yaml_config, "first.csv", CSV_CONTENT, account=self.checking)
|
||||
# Same rows, other account: not duplicates
|
||||
into_savings = self._import(
|
||||
yaml_config, "second.csv", CSV_CONTENT, account=self.savings
|
||||
)
|
||||
# Same rows, same account: duplicates
|
||||
into_savings_again = self._import(
|
||||
yaml_config, "third.csv", CSV_CONTENT, account=self.savings
|
||||
)
|
||||
|
||||
self.assertEqual(into_savings.successful_rows, 2)
|
||||
self.assertEqual(into_savings.skipped_rows, 0)
|
||||
self.assertEqual(into_savings_again.successful_rows, 0)
|
||||
self.assertEqual(into_savings_again.skipped_rows, 2)
|
||||
self.assertEqual(Transaction.objects.filter(account=self.savings).count(), 2)
|
||||
|
||||
def test_qif_run_account_is_used_instead_of_the_file_name(self):
|
||||
yaml_config = """
|
||||
settings:
|
||||
file_type: qif
|
||||
importing: transactions
|
||||
date_format: "%d/%m/%Y"
|
||||
mapping: {}
|
||||
"""
|
||||
content = """!Type:Bank
|
||||
D05/01/2026
|
||||
T-100.00
|
||||
PSupermarket
|
||||
^
|
||||
"""
|
||||
run = self._import(
|
||||
yaml_config, "export-2026.qif", content, account=self.savings
|
||||
)
|
||||
|
||||
self.assertEqual(run.status, ImportRun.Status.FINISHED)
|
||||
transaction = Transaction.objects.get()
|
||||
self.assertEqual(transaction.account, self.savings)
|
||||
self.assertEqual(transaction.amount, Decimal("100.00"))
|
||||
|
||||
|
||||
class ImportRunAccountTestCase(ImportObjectPermissionTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.temp_root = Path(tempfile.mkdtemp()).resolve()
|
||||
self.addCleanup(shutil.rmtree, self.temp_root, ignore_errors=True)
|
||||
temp_root_override = override_settings(IMPORT_TEMP_ROOT=self.temp_root)
|
||||
temp_root_override.enable()
|
||||
self.addCleanup(temp_root_override.disable)
|
||||
|
||||
currency = Currency.objects.create(
|
||||
code="EUR", name="Euro", decimal_places=2, prefix="€ "
|
||||
)
|
||||
self.account = Account.all_objects.create(
|
||||
name="Checking", currency=currency, owner=self.owner
|
||||
)
|
||||
self.archived_account = Account.all_objects.create(
|
||||
name="Old checking", currency=currency, owner=self.owner, is_archived=True
|
||||
)
|
||||
self.strangers_account = Account.all_objects.create(
|
||||
name="Stranger's checking", currency=currency, owner=self.stranger
|
||||
)
|
||||
self.tags_profile = ImportProfile.all_objects.create(
|
||||
name="Tags profile", yaml_config=TAGS_YAML_CONFIG, owner=self.owner
|
||||
)
|
||||
|
||||
|
||||
class ImportRunAccountViewTests(ImportRunAccountTestCase):
|
||||
def _upload_form(self, profile):
|
||||
self.login(self.owner)
|
||||
response = self.client.get(
|
||||
reverse("import_run_add", kwargs={"profile_id": profile.id}), **HTMX
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
return response.context["form"]
|
||||
|
||||
def test_upload_form_offers_active_accounts_for_transaction_profiles(self):
|
||||
form = self._upload_form(self.private_profile)
|
||||
|
||||
self.assertIn("account", form.fields)
|
||||
self.assertFalse(form.fields["account"].required)
|
||||
self.assertEqual(list(form.fields["account"].queryset), [self.account])
|
||||
|
||||
def test_upload_form_has_no_account_for_other_profiles(self):
|
||||
form = self._upload_form(self.tags_profile)
|
||||
|
||||
self.assertNotIn("account", form.fields)
|
||||
|
||||
@patch("apps.import_app.views.process_import.defer")
|
||||
def test_web_upload_stores_the_account_on_the_run(self, mock_defer):
|
||||
self.login(self.owner)
|
||||
response = self.client.post(
|
||||
reverse("import_run_add", kwargs={"profile_id": self.private_profile.id}),
|
||||
{
|
||||
"file": SimpleUploadedFile("web.csv", b"date,amount\n"),
|
||||
"account": self.account.id,
|
||||
},
|
||||
**HTMX,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 204)
|
||||
run = ImportRun.all_objects.get(file_name="web.csv")
|
||||
self.assertEqual(run.account, self.account)
|
||||
mock_defer.assert_called_once()
|
||||
|
||||
@patch("apps.import_app.views.process_import.defer")
|
||||
def test_web_upload_without_account_keeps_the_profile_account(self, mock_defer):
|
||||
self.login(self.owner)
|
||||
response = self.client.post(
|
||||
reverse("import_run_add", kwargs={"profile_id": self.private_profile.id}),
|
||||
{"file": SimpleUploadedFile("web.csv", b"date,amount\n")},
|
||||
**HTMX,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 204)
|
||||
self.assertIsNone(ImportRun.all_objects.get(file_name="web.csv").account)
|
||||
|
||||
@patch("apps.import_app.views.process_import.defer")
|
||||
def test_web_upload_rejects_another_users_account(self, mock_defer):
|
||||
self.login(self.owner)
|
||||
response = self.client.post(
|
||||
reverse("import_run_add", kwargs={"profile_id": self.private_profile.id}),
|
||||
{
|
||||
"file": SimpleUploadedFile("web.csv", b"date,amount\n"),
|
||||
"account": self.strangers_account.id,
|
||||
},
|
||||
**HTMX,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("account", response.context["form"].errors)
|
||||
self.assertFalse(ImportRun.all_objects.filter(file_name="web.csv").exists())
|
||||
mock_defer.assert_not_called()
|
||||
|
||||
def test_runs_list_shows_the_run_account(self):
|
||||
ImportRun.all_objects.create(
|
||||
profile=self.private_profile,
|
||||
file_name="statement.csv",
|
||||
account=self.account,
|
||||
owner=self.owner,
|
||||
)
|
||||
self.login(self.owner)
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"import_profile_runs_list",
|
||||
kwargs={"profile_id": self.private_profile.id},
|
||||
),
|
||||
**HTMX,
|
||||
)
|
||||
|
||||
self.assertContains(response, "Checking")
|
||||
|
||||
|
||||
class ImportRunAccountAPITests(ImportRunAccountTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.api = APIClient()
|
||||
self.api.force_authenticate(user=self.owner)
|
||||
|
||||
def _post(self, profile, **data):
|
||||
return self.api.post(
|
||||
"/api/import/import/",
|
||||
{
|
||||
"profile_id": profile.id,
|
||||
"file": SimpleUploadedFile("api.csv", b"date,amount\n"),
|
||||
**data,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
@patch("apps.api.views.imports.process_import.defer")
|
||||
def test_api_upload_stores_the_account_on_the_run(self, mock_defer):
|
||||
response = self._post(self.private_profile, account_id=self.account.id)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
||||
run = ImportRun.all_objects.get(id=response.data["import_run_id"])
|
||||
self.assertEqual(run.account, self.account)
|
||||
|
||||
response = self.api.get(f"/api/import/runs/{run.id}/")
|
||||
self.assertEqual(response.data["account"], self.account.id)
|
||||
|
||||
@patch("apps.api.views.imports.process_import.defer")
|
||||
def test_api_upload_without_account_keeps_the_profile_account(self, mock_defer):
|
||||
response = self._post(self.private_profile)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
||||
run = ImportRun.all_objects.get(id=response.data["import_run_id"])
|
||||
self.assertIsNone(run.account)
|
||||
|
||||
@patch("apps.api.views.imports.process_import.defer")
|
||||
def test_api_rejects_another_users_account(self, mock_defer):
|
||||
response = self._post(
|
||||
self.private_profile, account_id=self.strangers_account.id
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("account_id", response.data)
|
||||
mock_defer.assert_not_called()
|
||||
|
||||
@patch("apps.api.views.imports.process_import.defer")
|
||||
def test_api_rejects_an_account_for_profiles_not_importing_transactions(
|
||||
self, mock_defer
|
||||
):
|
||||
response = self._post(self.tags_profile, account_id=self.account.id)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("account_id", response.data)
|
||||
mock_defer.assert_not_called()
|
||||
@@ -168,7 +168,11 @@ def import_runs_list(request, profile_id):
|
||||
|
||||
# ImportRun.objects only returns the requesting user's runs, so a shared
|
||||
# profile does not expose the runs other users made with it.
|
||||
runs = ImportRun.objects.filter(profile=profile).order_by("-id")
|
||||
runs = (
|
||||
ImportRun.objects.filter(profile=profile)
|
||||
.select_related("account")
|
||||
.order_by("-id")
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
@@ -201,7 +205,7 @@ def import_run_add(request, profile_id):
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
form = ImportRunFileUploadForm(request.POST, request.FILES)
|
||||
form = ImportRunFileUploadForm(request.POST, request.FILES, profile=profile)
|
||||
|
||||
if form.is_valid():
|
||||
uploaded_file = request.FILES["file"]
|
||||
@@ -210,7 +214,10 @@ def import_run_add(request, profile_id):
|
||||
file_path = fs.path(filename)
|
||||
|
||||
import_run = ImportRun.objects.create(
|
||||
profile=profile, file_name=filename, owner=request.user
|
||||
profile=profile,
|
||||
file_name=filename,
|
||||
account=form.cleaned_data.get("account"),
|
||||
owner=request.user,
|
||||
)
|
||||
|
||||
# Defer the procrastinate task
|
||||
@@ -229,7 +236,7 @@ def import_run_add(request, profile_id):
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = ImportRunFileUploadForm()
|
||||
form = ImportRunFileUploadForm(profile=profile)
|
||||
|
||||
return render(
|
||||
request,
|
||||
|
||||
@@ -87,6 +87,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if run.account %}
|
||||
<div class="col">
|
||||
<div class="flex flex-row">
|
||||
<div class="flex flex-col">
|
||||
<div class="text-base-content/70 text-xs font-medium">
|
||||
{% trans 'Account' %}
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
{{ run.account.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-base-200 p-4 text-base-content/70">
|
||||
|
||||
Reference in new issue
Block a user