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 %} +
+
+ {% crispy form %} +
+
+{% 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 %} +
+
+ {% crispy form %} +
+
+{% 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 @@
  • {% 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