diff --git a/app/WYGIWYH/settings.py b/app/WYGIWYH/settings.py
index 86ddecc..aab680f 100644
--- a/app/WYGIWYH/settings.py
+++ b/app/WYGIWYH/settings.py
@@ -55,6 +55,7 @@ INSTALLED_APPS = [
"hijack",
"hijack.contrib.admin",
"django_filters",
+ "import_export",
"apps.users.apps.UsersConfig",
"procrastinate.contrib.django",
"apps.transactions.apps.TransactionsConfig",
@@ -63,6 +64,7 @@ INSTALLED_APPS = [
"apps.common.apps.CommonConfig",
"apps.net_worth.apps.NetWorthConfig",
"apps.import_app.apps.ImportConfig",
+ "apps.export_app.apps.ExportConfig",
"apps.api.apps.ApiConfig",
"cachalot",
"rest_framework",
diff --git a/app/WYGIWYH/urls.py b/app/WYGIWYH/urls.py
index bd17bac..a9612fb 100644
--- a/app/WYGIWYH/urls.py
+++ b/app/WYGIWYH/urls.py
@@ -49,5 +49,6 @@ urlpatterns = [
path("", include("apps.dca.urls")),
path("", include("apps.mini_tools.urls")),
path("", include("apps.import_app.urls")),
+ path("", include("apps.export_app.urls")),
path("", include("apps.insights.urls")),
]
diff --git a/app/apps/export_app/__init__.py b/app/apps/export_app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/apps/export_app/admin.py b/app/apps/export_app/admin.py
new file mode 100644
index 0000000..8c38f3f
--- /dev/null
+++ b/app/apps/export_app/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/app/apps/export_app/apps.py b/app/apps/export_app/apps.py
new file mode 100644
index 0000000..1744c75
--- /dev/null
+++ b/app/apps/export_app/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class ExportConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "apps.export_app"
diff --git a/app/apps/export_app/forms.py b/app/apps/export_app/forms.py
new file mode 100644
index 0000000..bc9569e
--- /dev/null
+++ b/app/apps/export_app/forms.py
@@ -0,0 +1,189 @@
+from crispy_forms.bootstrap import FormActions
+from crispy_forms.helper import FormHelper
+from crispy_forms.layout import Layout, HTML
+from django import forms
+from django.utils.translation import gettext_lazy as _
+
+from apps.common.widgets.crispy.submit import NoClassSubmit
+
+
+class ExportForm(forms.Form):
+ accounts = forms.BooleanField(
+ required=False,
+ widget=forms.CheckboxInput(),
+ label=_("Accounts"),
+ initial=True,
+ )
+ currencies = forms.BooleanField(
+ required=False,
+ widget=forms.CheckboxInput(),
+ label=_("Currencies"),
+ initial=True,
+ )
+ transactions = forms.BooleanField(
+ required=False,
+ widget=forms.CheckboxInput(),
+ label=_("Transactions"),
+ initial=True,
+ )
+ categories = forms.BooleanField(
+ required=False,
+ widget=forms.CheckboxInput(),
+ label=_("Categories"),
+ initial=True,
+ )
+ tags = forms.BooleanField(
+ required=False,
+ widget=forms.CheckboxInput(),
+ label=_("Tags"),
+ initial=False,
+ )
+ entities = forms.BooleanField(
+ required=False,
+ widget=forms.CheckboxInput(),
+ label=_("Entities"),
+ initial=False,
+ )
+ recurring_transactions = forms.BooleanField(
+ required=False,
+ widget=forms.CheckboxInput(),
+ label=_("Recurring Transactions"),
+ initial=True,
+ )
+ installment_plans = forms.BooleanField(
+ required=False,
+ widget=forms.CheckboxInput(),
+ label=_("Installment Planss"),
+ initial=True,
+ )
+ exchange_rates = forms.BooleanField(
+ required=False,
+ widget=forms.CheckboxInput(),
+ label=_("Exchange Rates"),
+ initial=False,
+ )
+ exchange_rates_services = forms.BooleanField(
+ required=False,
+ widget=forms.CheckboxInput(),
+ label=_("Automatic Exchange Rates"),
+ initial=False,
+ )
+ rules = forms.BooleanField(
+ required=False,
+ widget=forms.CheckboxInput(),
+ label=_("Rules"),
+ initial=True,
+ )
+ dca = forms.BooleanField(
+ required=False,
+ widget=forms.CheckboxInput(),
+ label=_("DCA"),
+ initial=False,
+ )
+ import_profiles = forms.BooleanField(
+ required=False,
+ widget=forms.CheckboxInput(),
+ label=_("Import Profiles"),
+ initial=True,
+ )
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ self.helper = FormHelper()
+ self.helper.form_tag = False
+ self.helper.form_method = "post"
+ self.helper.layout = Layout(
+ "accounts",
+ "currencies",
+ "transactions",
+ "categories",
+ "entities",
+ "tags",
+ "installment_plans",
+ "recurring_transactions",
+ "exchange_rates_services",
+ "exchange_rates",
+ "rules",
+ "dca",
+ "import_profiles",
+ FormActions(
+ NoClassSubmit(
+ "submit", _("Export"), css_class="btn btn-outline-primary w-100"
+ ),
+ ),
+ )
+
+
+class RestoreForm(forms.Form):
+ zip_file = forms.FileField(
+ required=False,
+ help_text=_("Import a ZIP file exported from WYGIWYH"),
+ label=_("ZIP File"),
+ )
+ accounts = forms.FileField(required=False, label=_("Accounts"))
+ currencies = forms.FileField(required=False, label=_("Currencies"))
+ transactions_categories = forms.FileField(required=False, label=_("Categories"))
+ transactions_tags = forms.FileField(required=False, label=_("Tags"))
+ transactions_entities = forms.FileField(required=False, label=_("Entities"))
+ transactions = forms.FileField(required=False, label=_("Transactions"))
+ installment_plans = forms.FileField(required=False, label=_("Installment Plans"))
+ recurring_transactions = forms.FileField(
+ required=False, label=_("Recurring Transactions")
+ )
+ automatic_exchange_rates = forms.FileField(
+ required=False, label=_("Automatic Exchange Rates")
+ )
+ exchange_rates = forms.FileField(required=False, label=_("Exchange Rates"))
+ transaction_rules = forms.FileField(required=False, label=_("Transaction rules"))
+ transaction_rules_actions = forms.FileField(
+ required=False, label=_("Edit transaction action")
+ )
+ transaction_rules_update_or_create = forms.FileField(
+ required=False, label=_("Update or create transaction actions")
+ )
+ dca_strategies = forms.FileField(required=False, label=_("DCA Strategies"))
+ dca_entries = forms.FileField(required=False, label=_("DCA Entries"))
+ import_profiles = forms.FileField(required=False, label=_("Import Profiles"))
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ self.helper = FormHelper()
+ self.helper.form_tag = False
+ self.helper.form_method = "post"
+ self.helper.layout = Layout(
+ "zip_file",
+ HTML("
"),
+ "accounts",
+ "currencies",
+ "transactions",
+ "transactions_categories",
+ "transactions_entities",
+ "transactions_tags",
+ "installment_plans",
+ "recurring_transactions",
+ "automatic_exchange_rates",
+ "exchange_rates",
+ "transaction_rules",
+ "transaction_rules_actions",
+ "transaction_rules_update_or_create",
+ "dca_strategies",
+ "dca_entries",
+ "import_profiles",
+ FormActions(
+ NoClassSubmit(
+ "submit", _("Restore"), css_class="btn btn-outline-primary w-100"
+ ),
+ ),
+ )
+
+ def clean(self):
+ cleaned_data = super().clean()
+ if not cleaned_data.get("zip_file") and not any(
+ cleaned_data.get(field) for field in self.fields if field != "zip_file"
+ ):
+ raise forms.ValidationError(
+ _("Please upload either a ZIP file or at least one CSV file")
+ )
+ return cleaned_data
diff --git a/app/apps/export_app/migrations/__init__.py b/app/apps/export_app/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/apps/export_app/models.py b/app/apps/export_app/models.py
new file mode 100644
index 0000000..71a8362
--- /dev/null
+++ b/app/apps/export_app/models.py
@@ -0,0 +1,3 @@
+from django.db import models
+
+# Create your models here.
diff --git a/app/apps/export_app/resources/__init__.py b/app/apps/export_app/resources/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/apps/export_app/resources/accounts.py b/app/apps/export_app/resources/accounts.py
new file mode 100644
index 0000000..a1b8eb3
--- /dev/null
+++ b/app/apps/export_app/resources/accounts.py
@@ -0,0 +1,26 @@
+from import_export import fields, resources, widgets
+
+from apps.accounts.models import Account, AccountGroup
+from apps.export_app.widgets.foreign_key import AutoCreateForeignKeyWidget
+from apps.currencies.models import Currency
+
+
+class AccountResource(resources.ModelResource):
+ group = fields.Field(
+ attribute="group",
+ column_name="group",
+ widget=AutoCreateForeignKeyWidget(AccountGroup, "name"),
+ )
+ currency = fields.Field(
+ attribute="currency",
+ column_name="currency",
+ widget=widgets.ForeignKeyWidget(Currency, "name"),
+ )
+ exchange_currency = fields.Field(
+ attribute="exchange_currency",
+ column_name="exchange_currency",
+ widget=widgets.ForeignKeyWidget(Currency, "name"),
+ )
+
+ class Meta:
+ model = Account
diff --git a/app/apps/export_app/resources/currencies.py b/app/apps/export_app/resources/currencies.py
new file mode 100644
index 0000000..c74ea95
--- /dev/null
+++ b/app/apps/export_app/resources/currencies.py
@@ -0,0 +1,47 @@
+from import_export import fields, resources, widgets
+
+from apps.accounts.models import Account
+from apps.currencies.models import Currency, ExchangeRate, ExchangeRateService
+
+
+class CurrencyResource(resources.ModelResource):
+ exchange_currency = fields.Field(
+ attribute="exchange_currency",
+ column_name="exchange_currency",
+ widget=widgets.ForeignKeyWidget(Currency, "name"),
+ )
+
+ class Meta:
+ model = Currency
+
+
+class ExchangeRateResource(resources.ModelResource):
+ from_currency = fields.Field(
+ attribute="from_currency",
+ column_name="from_currency",
+ widget=widgets.ForeignKeyWidget(Currency, "name"),
+ )
+ to_currency = fields.Field(
+ attribute="to_currency",
+ column_name="to_currency",
+ widget=widgets.ForeignKeyWidget(Currency, "name"),
+ )
+
+ class Meta:
+ model = ExchangeRate
+
+
+class ExchangeRateServiceResource(resources.ModelResource):
+ target_currencies = fields.Field(
+ attribute="target_currencies",
+ column_name="target_currencies",
+ widget=widgets.ManyToManyWidget(Currency, field="name"),
+ )
+ target_accounts = fields.Field(
+ attribute="target_accounts",
+ column_name="target_accounts",
+ widget=widgets.ManyToManyWidget(Account, field="name"),
+ )
+
+ class Meta:
+ model = ExchangeRateService
diff --git a/app/apps/export_app/resources/dca.py b/app/apps/export_app/resources/dca.py
new file mode 100644
index 0000000..e9345cb
--- /dev/null
+++ b/app/apps/export_app/resources/dca.py
@@ -0,0 +1,26 @@
+from import_export import fields, resources
+from import_export.widgets import ForeignKeyWidget
+
+from apps.dca.models import DCAStrategy, DCAEntry
+from apps.currencies.models import Currency
+
+
+class DCAStrategyResource(resources.ModelResource):
+ target_currency = fields.Field(
+ attribute="target_currency",
+ column_name="target_currency",
+ widget=ForeignKeyWidget(Currency, "name"),
+ )
+ payment_currency = fields.Field(
+ attribute="payment_currency",
+ column_name="payment_currency",
+ widget=ForeignKeyWidget(Currency, "name"),
+ )
+
+ class Meta:
+ model = DCAStrategy
+
+
+class DCAEntryResource(resources.ModelResource):
+ class Meta:
+ model = DCAEntry
diff --git a/app/apps/export_app/resources/import_app.py b/app/apps/export_app/resources/import_app.py
new file mode 100644
index 0000000..043bfbd
--- /dev/null
+++ b/app/apps/export_app/resources/import_app.py
@@ -0,0 +1,8 @@
+from import_export import resources
+
+from apps.import_app.models import ImportProfile
+
+
+class ImportProfileResource(resources.ModelResource):
+ class Meta:
+ model = ImportProfile
diff --git a/app/apps/export_app/resources/rules.py b/app/apps/export_app/resources/rules.py
new file mode 100644
index 0000000..dcc549e
--- /dev/null
+++ b/app/apps/export_app/resources/rules.py
@@ -0,0 +1,25 @@
+from import_export import fields, resources
+from import_export.widgets import ForeignKeyWidget
+
+from apps.export_app.widgets.foreign_key import AutoCreateForeignKeyWidget
+from apps.export_app.widgets.many_to_many import AutoCreateManyToManyWidget
+from apps.rules.models import (
+ TransactionRule,
+ TransactionRuleAction,
+ UpdateOrCreateTransactionRuleAction,
+)
+
+
+class TransactionRuleResource(resources.ModelResource):
+ class Meta:
+ model = TransactionRule
+
+
+class TransactionRuleActionResource(resources.ModelResource):
+ class Meta:
+ model = TransactionRuleAction
+
+
+class UpdateOrCreateTransactionRuleResource(resources.ModelResource):
+ class Meta:
+ model = UpdateOrCreateTransactionRuleAction
diff --git a/app/apps/export_app/resources/transactions.py b/app/apps/export_app/resources/transactions.py
new file mode 100644
index 0000000..9644cd2
--- /dev/null
+++ b/app/apps/export_app/resources/transactions.py
@@ -0,0 +1,124 @@
+from import_export import fields, resources
+from import_export.widgets import ForeignKeyWidget
+
+from apps.accounts.models import Account
+from apps.export_app.widgets.foreign_key import AutoCreateForeignKeyWidget
+from apps.export_app.widgets.many_to_many import AutoCreateManyToManyWidget
+from apps.export_app.widgets.string import EmptyStringToNoneField
+from apps.transactions.models import (
+ Transaction,
+ TransactionCategory,
+ TransactionTag,
+ TransactionEntity,
+ RecurringTransaction,
+ InstallmentPlan,
+)
+
+
+class TransactionResource(resources.ModelResource):
+ account = fields.Field(
+ attribute="account",
+ column_name="account",
+ widget=ForeignKeyWidget(Account, "name"),
+ )
+
+ category = fields.Field(
+ attribute="category",
+ column_name="category",
+ widget=AutoCreateForeignKeyWidget(TransactionCategory, "name"),
+ )
+
+ tags = fields.Field(
+ attribute="tags",
+ column_name="tags",
+ widget=AutoCreateManyToManyWidget(TransactionTag, field="name"),
+ )
+
+ entities = fields.Field(
+ attribute="entities",
+ column_name="entities",
+ widget=AutoCreateManyToManyWidget(TransactionEntity, field="name"),
+ )
+
+ internal_id = EmptyStringToNoneField(
+ column_name="internal_id", attribute="internal_id"
+ )
+
+ class Meta:
+ model = Transaction
+
+ def get_queryset(self):
+ return Transaction.all_objects.all()
+
+
+class TransactionTagResource(resources.ModelResource):
+ class Meta:
+ model = TransactionTag
+
+
+class TransactionEntityResource(resources.ModelResource):
+ class Meta:
+ model = TransactionEntity
+
+
+class TransactionCategoyResource(resources.ModelResource):
+ class Meta:
+ model = TransactionCategory
+
+
+class RecurringTransactionResource(resources.ModelResource):
+ account = fields.Field(
+ attribute="account",
+ column_name="account",
+ widget=ForeignKeyWidget(Account, "name"),
+ )
+
+ category = fields.Field(
+ attribute="category",
+ column_name="category",
+ widget=AutoCreateForeignKeyWidget(TransactionCategory, "name"),
+ )
+
+ tags = fields.Field(
+ attribute="tags",
+ column_name="tags",
+ widget=AutoCreateManyToManyWidget(TransactionTag, field="name"),
+ )
+
+ entities = fields.Field(
+ attribute="entities",
+ column_name="entities",
+ widget=AutoCreateManyToManyWidget(TransactionEntity, field="name"),
+ )
+
+ class Meta:
+ model = RecurringTransaction
+
+
+class InstallmentPlanResource(resources.ModelResource):
+ account = fields.Field(
+ attribute="account",
+ column_name="account",
+ widget=ForeignKeyWidget(Account, "name"),
+ )
+
+ category = fields.Field(
+ attribute="category",
+ column_name="category",
+ widget=AutoCreateForeignKeyWidget(TransactionCategory, "name"),
+ )
+
+ tags = fields.Field(
+ attribute="tags",
+ column_name="tags",
+ widget=AutoCreateManyToManyWidget(TransactionTag, field="name"),
+ )
+
+ entities = fields.Field(
+ attribute="entities",
+ column_name="entities",
+ widget=AutoCreateManyToManyWidget(TransactionEntity, field="name"),
+ )
+
+ class Meta:
+ model = InstallmentPlan
diff --git a/app/apps/export_app/tests.py b/app/apps/export_app/tests.py
new file mode 100644
index 0000000..7ce503c
--- /dev/null
+++ b/app/apps/export_app/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/app/apps/export_app/urls.py b/app/apps/export_app/urls.py
new file mode 100644
index 0000000..5b43a85
--- /dev/null
+++ b/app/apps/export_app/urls.py
@@ -0,0 +1,8 @@
+from django.urls import path
+import apps.export_app.views as views
+
+urlpatterns = [
+ path("export/", views.export_index, name="export_index"),
+ path("export/form/", views.export_form, name="export_form"),
+ path("export/restore/", views.import_form, name="restore_form"),
+]
diff --git a/app/apps/export_app/views.py b/app/apps/export_app/views.py
new file mode 100644
index 0000000..3bfc0e5
--- /dev/null
+++ b/app/apps/export_app/views.py
@@ -0,0 +1,284 @@
+import logging
+import zipfile
+from io import BytesIO, TextIOWrapper
+
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
+from django.db import transaction
+from django.http import HttpResponse
+from django.shortcuts import render
+from django.utils import timezone
+from django.utils.translation import gettext_lazy as _
+from django.views.decorators.http import require_http_methods
+from tablib import Dataset
+
+from apps.export_app.forms import ExportForm, RestoreForm
+from apps.export_app.resources.accounts import AccountResource
+from apps.export_app.resources.transactions import (
+ TransactionResource,
+ TransactionTagResource,
+ TransactionEntityResource,
+ TransactionCategoyResource,
+ InstallmentPlanResource,
+ RecurringTransactionResource,
+)
+from apps.export_app.resources.currencies import (
+ CurrencyResource,
+ ExchangeRateResource,
+ ExchangeRateServiceResource,
+)
+from apps.export_app.resources.rules import (
+ TransactionRuleResource,
+ TransactionRuleActionResource,
+ UpdateOrCreateTransactionRuleResource,
+)
+from apps.export_app.resources.dca import (
+ DCAStrategyResource,
+ DCAEntryResource,
+)
+from apps.export_app.resources.import_app import (
+ ImportProfileResource,
+)
+from apps.common.decorators.htmx import only_htmx
+
+logger = logging.getLogger()
+
+
+@login_required
+@require_http_methods(["GET"])
+def export_index(request):
+ return render(request, "export_app/pages/index.html")
+
+
+@only_htmx
+@login_required
+@require_http_methods(["GET", "POST"])
+def export_form(request):
+ timestamp = timezone.localtime(timezone.now()).strftime("%Y-%m-%dT%H-%M-%S")
+
+ if request.method == "POST":
+ form = ExportForm(request.POST)
+ if form.is_valid():
+ zip_buffer = BytesIO()
+
+ export_accounts = form.cleaned_data.get("accounts", False)
+ export_currencies = form.cleaned_data.get("currencies", False)
+ export_transactions = form.cleaned_data.get("transactions", False)
+ export_categories = form.cleaned_data.get("categories", False)
+ export_tags = form.cleaned_data.get("tags", False)
+ export_entities = form.cleaned_data.get("entities", False)
+ export_installment_plans = form.cleaned_data.get("installment_plans", False)
+ export_recurring_transactions = form.cleaned_data.get(
+ "recurring_transactions", False
+ )
+
+ export_exchange_rates_services = form.cleaned_data.get(
+ "exchange_rates_services", False
+ )
+ export_exchange_rates = form.cleaned_data.get("exchange_rates", False)
+ export_rules = form.cleaned_data.get("rules", False)
+ export_dca = form.cleaned_data.get("dca", False)
+ export_import_profiles = form.cleaned_data.get("import_profiles", False)
+
+ exports = []
+ if export_accounts:
+ exports.append((AccountResource().export(), "accounts"))
+ if export_currencies:
+ exports.append((CurrencyResource().export(), "currencies"))
+ if export_transactions:
+ exports.append((TransactionResource().export(), "transactions"))
+ if export_categories:
+ exports.append(
+ (TransactionCategoyResource().export(), "transactions_categories")
+ )
+ if export_tags:
+ exports.append((TransactionTagResource().export(), "transactions_tags"))
+ if export_entities:
+ exports.append(
+ (TransactionEntityResource().export(), "transactions_entities")
+ )
+ if export_installment_plans:
+ exports.append(
+ (InstallmentPlanResource().export(), "installment_plans")
+ )
+ if export_recurring_transactions:
+ exports.append(
+ (RecurringTransactionResource().export(), "recurring_transactions")
+ )
+ if export_exchange_rates_services:
+ exports.append(
+ (ExchangeRateServiceResource().export(), "automatic_exchange_rates")
+ )
+ if export_exchange_rates:
+ exports.append((ExchangeRateResource().export(), "exchange_rates"))
+ if export_rules:
+ exports.append(
+ (TransactionRuleResource().export(), "transaction_rules")
+ )
+ exports.append(
+ (
+ TransactionRuleActionResource().export(),
+ "transaction_rules_actions",
+ )
+ )
+ exports.append(
+ (
+ UpdateOrCreateTransactionRuleResource().export(),
+ "transaction_rules_update_or_create",
+ )
+ )
+ if export_dca:
+ exports.append((DCAStrategyResource().export(), "dca_strategies"))
+ exports.append(
+ (
+ DCAEntryResource().export(),
+ "dca_entries",
+ )
+ )
+ if export_import_profiles:
+ exports.append((ImportProfileResource().export(), "import_profiles"))
+
+ if len(exports) >= 2:
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
+ for dataset, name in exports:
+ zip_file.writestr(f"{name}.csv", dataset.csv)
+
+ response = HttpResponse(
+ zip_buffer.getvalue(),
+ content_type="application/zip",
+ headers={
+ "HX-Trigger": "hide_offcanvas, updated",
+ "Content-Disposition": f'attachment; filename="{timestamp}_WYGIWYH_export.zip"',
+ },
+ )
+ return response
+ elif len(exports) == 1:
+ dataset, name = exports[0]
+
+ response = HttpResponse(
+ dataset.csv,
+ content_type="text/csv",
+ headers={
+ "HX-Trigger": "hide_offcanvas, updated",
+ "Content-Disposition": f'attachment; filename="{timestamp}_WYGIWYH_export_{name}.csv"',
+ },
+ )
+ return response
+ else:
+ return HttpResponse(
+ _("You have to select at least one export"),
+ )
+
+ else:
+ form = ExportForm()
+
+ return render(request, "export_app/fragments/export.html", context={"form": form})
+
+
+@only_htmx
+@login_required
+@require_http_methods(["GET", "POST"])
+def import_form(request):
+ if request.method == "POST":
+ form = RestoreForm(request.POST, request.FILES)
+ if form.is_valid():
+ try:
+ process_imports(request, form.cleaned_data)
+ messages.success(request, _("Data imported successfully"))
+ return HttpResponse(
+ status=204,
+ headers={
+ "HX-Trigger": "hide_offcanvas, updated",
+ },
+ )
+ except Exception as e:
+ logger.error("Error importing", exc_info=e)
+ messages.error(
+ request,
+ _("There was an error importing. Check the logs for more details."),
+ )
+ else:
+ form = RestoreForm()
+
+ response = render(request, "export_app/fragments/restore.html", {"form": form})
+ response["HX-Trigger"] = "updated"
+ return response
+
+
+def process_imports(request, cleaned_data):
+ # Define import order to handle dependencies
+ import_order = [
+ ("currencies", CurrencyResource),
+ (
+ "currencies",
+ CurrencyResource,
+ ), # We do a double pass because exchange_currency may not exist when currency is initially created
+ ("accounts", AccountResource),
+ ("transactions_categories", TransactionCategoyResource),
+ ("transactions_tags", TransactionTagResource),
+ ("transactions_entities", TransactionEntityResource),
+ ("automatic_exchange_rates", ExchangeRateServiceResource),
+ ("exchange_rates", ExchangeRateResource),
+ ("installment_plans", InstallmentPlanResource),
+ ("recurring_transactions", RecurringTransactionResource),
+ ("transactions", TransactionResource),
+ ("dca_strategies", DCAStrategyResource),
+ ("dca_entries", DCAEntryResource),
+ ("import_profiles", ImportProfileResource),
+ ("transaction_rules", TransactionRuleResource),
+ ("transaction_rules_actions", TransactionRuleActionResource),
+ ("transaction_rules_update_or_create", UpdateOrCreateTransactionRuleResource),
+ ]
+
+ def import_dataset(content, resource_class, field_name):
+ try:
+ # Create a new resource instance
+ resource = resource_class()
+
+ # Create dataset from CSV content
+ dataset = Dataset()
+ dataset.load(content, format="csv")
+
+ # Debug logging
+ logger.debug(f"Importing {field_name}")
+ logger.debug(f"Headers: {dataset.headers}")
+ logger.debug(f"First row: {dataset[0] if len(dataset) > 0 else 'No data'}")
+
+ # Perform the import
+ result = resource.import_data(
+ dataset,
+ dry_run=False,
+ raise_errors=True,
+ collect_failed_rows=True,
+ use_transactions=False,
+ skip_unchanged=True,
+ )
+
+ if result.has_errors():
+ raise ImportError(f"Failed rows: {result.failed_dataset}")
+
+ return result
+
+ except Exception as e:
+ logger.error(f"Error importing {field_name}: {str(e)}")
+ raise ImportError(f"Error importing {field_name}: {str(e)}")
+
+ with transaction.atomic():
+ if zip_file := cleaned_data.get("zip_file"):
+ # Process ZIP file
+ with zipfile.ZipFile(zip_file) as z:
+ for filename in z.namelist():
+ name = filename.replace(".csv", "")
+ with z.open(filename) as f:
+ content = f.read().decode("utf-8")
+
+ for field_name, resource_class in import_order:
+ if name == field_name:
+ import_dataset(content, resource_class, field_name)
+ break
+ else:
+ # Process individual files
+ for field_name, resource_class in import_order:
+ if csv_file := cleaned_data.get(field_name):
+ content = csv_file.read().decode("utf-8")
+ import_dataset(content, resource_class, field_name)
diff --git a/app/apps/export_app/widgets/__init__.py b/app/apps/export_app/widgets/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/apps/export_app/widgets/foreign_key.py b/app/apps/export_app/widgets/foreign_key.py
new file mode 100644
index 0000000..232a810
--- /dev/null
+++ b/app/apps/export_app/widgets/foreign_key.py
@@ -0,0 +1,11 @@
+from import_export.widgets import ForeignKeyWidget
+
+
+class AutoCreateForeignKeyWidget(ForeignKeyWidget):
+ def clean(self, value, row=None, *args, **kwargs):
+ if value:
+ try:
+ return super().clean(value, row, **kwargs)
+ except self.model.DoesNotExist:
+ return self.model.objects.create(name=value)
+ return None
diff --git a/app/apps/export_app/widgets/many_to_many.py b/app/apps/export_app/widgets/many_to_many.py
new file mode 100644
index 0000000..b559e3a
--- /dev/null
+++ b/app/apps/export_app/widgets/many_to_many.py
@@ -0,0 +1,21 @@
+from import_export.widgets import ManyToManyWidget
+
+
+class AutoCreateManyToManyWidget(ManyToManyWidget):
+ def clean(self, value, row=None, *args, **kwargs):
+ if not value:
+ return []
+
+ values = value.split(self.separator)
+ cleaned_values = []
+
+ for val in values:
+ val = val.strip()
+ if val:
+ try:
+ obj = self.model.objects.get(**{self.field: val})
+ except self.model.DoesNotExist:
+ obj = self.model.objects.create(name=val)
+ cleaned_values.append(obj)
+
+ return cleaned_values
diff --git a/app/apps/export_app/widgets/string.py b/app/apps/export_app/widgets/string.py
new file mode 100644
index 0000000..7a133aa
--- /dev/null
+++ b/app/apps/export_app/widgets/string.py
@@ -0,0 +1,7 @@
+from import_export import fields
+
+
+class EmptyStringToNoneField(fields.Field):
+ def clean(self, data, **kwargs):
+ value = super().clean(data)
+ return None if value == "" else value
diff --git a/app/templates/export_app/fragments/export.html b/app/templates/export_app/fragments/export.html
new file mode 100644
index 0000000..241dd2f
--- /dev/null
+++ b/app/templates/export_app/fragments/export.html
@@ -0,0 +1,13 @@
+{% extends "extends/offcanvas.html" %}
+{% load crispy_forms_tags %}
+{% load i18n %}
+
+{% block title %}{% translate 'Export' %}{% endblock %}
+
+{% block body %}
+
+
+
+{% endblock %}
diff --git a/app/templates/export_app/fragments/restore.html b/app/templates/export_app/fragments/restore.html
new file mode 100644
index 0000000..c158a75
--- /dev/null
+++ b/app/templates/export_app/fragments/restore.html
@@ -0,0 +1,17 @@
+{% extends "extends/offcanvas.html" %}
+{% load crispy_forms_tags %}
+{% load i18n %}
+
+{% block title %}{% translate 'Restore' %}{% endblock %}
+
+{% block body %}
+
+
+
+{% endblock %}
diff --git a/app/templates/export_app/pages/index.html b/app/templates/export_app/pages/index.html
new file mode 100644
index 0000000..c8bd992
--- /dev/null
+++ b/app/templates/export_app/pages/index.html
@@ -0,0 +1,29 @@
+{% extends "layouts/base.html" %}
+{% load i18n %}
+
+{% block title %}{% translate 'Export and Restore' %}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/app/templates/includes/navbar.html b/app/templates/includes/navbar.html
index 53c06c0..1724b55 100644
--- a/app/templates/includes/navbar.html
+++ b/app/templates/includes/navbar.html
@@ -94,7 +94,7 @@
-
@@ -132,6 +132,8 @@
href="{% url 'rules_index' %}">{% translate 'Rules' %}
{% translate 'Import' %} beta
+ {% translate 'Export and Restore' %}
{% translate 'Automatic Exchange Rates' %}
diff --git a/requirements.txt b/requirements.txt
index b7e8ae1..a9ce110 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -12,6 +12,7 @@ django-cotton~=1.2.1
django-pwa~=2.0.1
djangorestframework~=3.15.2
drf-spectacular~=0.27.2
+django-import-export~=4.3.5
gunicorn==22.0.0
whitenoise[brotli]==6.6.0