Compare commits

...

71 Commits
0.6.0 ... 0.7.0

Author SHA1 Message Date
Herculino Trotta
f2d32fd7e9 feat(import): final changes for release 2025-01-23 23:52:54 -03:00
Herculino Trotta
53175aacb9 feat(import:templates): change wrong name 2025-01-23 22:49:09 -03:00
Herculino Trotta
1dc03b0a84 feat(import:v1:service): respect create and type fields 2025-01-23 22:48:23 -03:00
Herculino Trotta
ba2d654f15 feat(accounts): make account names unique 2025-01-23 22:03:02 -03:00
Herculino Trotta
93d04572df feat(accounts): make account names unique 2025-01-23 22:02:45 -03:00
Herculino Trotta
38379ab2b1 feat(import): try to be more aggressive on cache invalidation 2025-01-23 21:12:13 -03:00
Herculino Trotta
928ad33111 feat(import): move required field check to end of process 2025-01-23 21:09:53 -03:00
Herculino Trotta
d0172b5524 feat(import): convert deduplicate fields field into list 2025-01-23 21:09:21 -03:00
Herculino Trotta
e4a2b83c83 feat: add new envs 2025-01-23 21:08:12 -03:00
Herculino Trotta
1c28dd5513 feat(import): show error if YAML is invalid 2025-01-23 21:08:03 -03:00
Herculino Trotta
1c713fac19 feat(import): add Nuconta preset 2025-01-23 21:07:48 -03:00
Herculino Trotta
096f24e0a2 feat(import): cleanup 2025-01-23 16:32:08 -03:00
Herculino Trotta
f1cd658972 Merge pull request #58
feat: beta import function
2025-01-23 14:34:02 -03:00
Herculino Trotta
a85221468a Merge remote-tracking branch 'origin/main' into 41-import-export-function
# Conflicts:
#	app/WYGIWYH/settings.py
2025-01-23 14:32:16 -03:00
Herculino Trotta
e3d3a7cf91 feat: add new envs 2025-01-23 14:30:59 -03:00
Herculino Trotta
4ef4609a96 fix(navbar): wrong active link for navbar import item 2025-01-23 14:24:31 -03:00
Herculino Trotta
962a8efa26 feat(navbar): add import to management menu 2025-01-23 14:04:58 -03:00
Herculino Trotta
d7de6c17a9 refactor: remove django-ace for now 2025-01-23 14:04:40 -03:00
Herculino Trotta
a805880e9b git: keep import_presets folder 2025-01-23 12:55:01 -03:00
Herculino Trotta
aaee602b71 refactor: remove django-ace for now 2025-01-23 12:54:26 -03:00
Herculino Trotta
7635b66638 Merge pull request #57
feat: PWA support
2025-01-23 12:50:17 -03:00
Herculino Trotta
bcc96588bf feat: PWA support 2025-01-23 12:49:50 -03:00
Herculino Trotta
cabd03e7e6 feat: presets 2025-01-23 11:43:35 -03:00
Herculino Trotta
16fbead2f9 Merge remote-tracking branch 'origin/41-import-export-function' into 41-import-export-function 2025-01-22 10:44:36 -03:00
Herculino Trotta
ece44f2726 feat(import): more UI and endpoints 2025-01-22 10:43:19 -03:00
Herculino Trotta
a415e285ee feat(transactions): make deleted_at readonly on admin 2025-01-22 10:43:18 -03:00
Herculino Trotta
00b8727664 feat(transactions): add internal_id field to transactions 2025-01-22 10:43:18 -03:00
Herculino Trotta
6f096fd3ff feat(import): some views and urls 2025-01-22 10:43:18 -03:00
Herculino Trotta
07fcbe1f45 feat(import): some layouts 2025-01-22 10:43:18 -03:00
Herculino Trotta
0f14fd0c62 feat(import): test yaml_config before saving 2025-01-22 10:43:18 -03:00
Herculino Trotta
61d5aba67c feat(import): some layouts 2025-01-22 10:43:18 -03:00
Herculino Trotta
76df16e489 feat(import:v1:schema): add option for triggering rules 2025-01-22 10:43:18 -03:00
Herculino Trotta
34e6914d41 feat(transactions:tasks): add old deleted transactions cleanup task 2025-01-22 10:43:18 -03:00
Herculino Trotta
f2cc070505 feat(settings): add KEEP_DELETED_TRANSACTIONS_FOR variable 2025-01-22 10:43:18 -03:00
Herculino Trotta
18d8e8ed1a feat(import): add migrations 2025-01-22 10:43:18 -03:00
Herculino Trotta
2ff33526ae feat(import): disable cache when running 2025-01-22 10:43:18 -03:00
Herculino Trotta
8a127a9f4f feat(transactions): soft delete 2025-01-22 10:43:17 -03:00
Herculino Trotta
a52f682c4f feat(transactions): soft delete 2025-01-22 10:43:17 -03:00
Herculino Trotta
3440d4405e docker: add temp volume 2025-01-22 10:43:17 -03:00
Herculino Trotta
87345cf235 docs(requirements): add django_ace 2025-01-22 10:43:17 -03:00
Herculino Trotta
50efc51f87 feat(import): improve schema definition 2025-01-22 10:43:17 -03:00
Herculino Trotta
493bf268bb feat: rename app, some work on schema 2025-01-22 10:43:17 -03:00
Herculino Trotta
8992cd98b5 feat: add import app boilerplate 2025-01-22 10:43:17 -03:00
Herculino Trotta
f7c3a2f320 locale: add nl (Dutch) language files 2025-01-22 10:21:35 -03:00
Herculino Trotta
d96787cfeb feat(import): more UI and endpoints 2025-01-22 01:41:17 -03:00
Herculino Trotta
32b5864736 feat(transactions): make deleted_at readonly on admin 2025-01-20 23:10:11 -03:00
Herculino Trotta
02adfd828a feat(transactions): add internal_id field to transactions 2025-01-20 23:09:49 -03:00
Herculino Trotta
c14b666921 Merge pull request #54 from eitchtee/datepicker_today_button
feat(datepicker): bring back today/now button behavior
2025-01-20 22:15:44 -03:00
Herculino Trotta
5d2b9ae0b3 locale(pt-BR): update translation 2025-01-20 22:14:42 -03:00
Herculino Trotta
d5dfe5bba0 feat(datepicker): bring back today/now button behavior 2025-01-20 22:14:36 -03:00
Herculino Trotta
72ceec7452 Merge pull request #53 from eitchtee/50-date-notation
fix(datepicker): missing leading zeros on times
2025-01-20 21:49:14 -03:00
Herculino Trotta
eae0e00d1f fix(datepicker): missing leading zeros on times 2025-01-20 21:48:09 -03:00
Herculino Trotta
cc0125241f Merge pull request #52
locale(pt-BR): update translation
2025-01-20 19:47:26 -03:00
Herculino Trotta
e3bab503a0 locale(pt-BR): update translation 2025-01-20 19:46:50 -03:00
Herculino Trotta
c089c49b7d refactor: remove debug print 2025-01-20 19:40:33 -03:00
Herculino Trotta
0fccdbe573 feat(import): some views and urls 2025-01-20 14:31:12 -03:00
Herculino Trotta
b9810ce062 feat(import): some layouts 2025-01-20 14:30:59 -03:00
Herculino Trotta
4cc32e3f57 feat(import): test yaml_config before saving 2025-01-20 14:30:40 -03:00
Herculino Trotta
8db13b082b feat(import): some layouts 2025-01-20 14:30:17 -03:00
Herculino Trotta
e73e1dfc25 feat(import:v1:schema): add option for triggering rules 2025-01-19 15:20:25 -03:00
Herculino Trotta
ae91c51967 feat(transactions:tasks): add old deleted transactions cleanup task 2025-01-19 15:17:18 -03:00
Herculino Trotta
3ef6b0ac5c feat(settings): add KEEP_DELETED_TRANSACTIONS_FOR variable 2025-01-19 15:16:47 -03:00
Herculino Trotta
ba0c54767c feat(import): add migrations 2025-01-19 13:56:29 -03:00
Herculino Trotta
2d8864773c feat(import): disable cache when running 2025-01-19 13:56:13 -03:00
Herculino Trotta
f96d8d2862 feat(transactions): soft delete 2025-01-19 13:55:25 -03:00
Herculino Trotta
3ccb0e19eb feat(transactions): soft delete 2025-01-19 13:55:17 -03:00
Herculino Trotta
238f205513 docker: add temp volume 2025-01-19 11:47:33 -03:00
Herculino Trotta
a94e0b4904 docs(requirements): add django_ace 2025-01-19 11:45:06 -03:00
Herculino Trotta
86dac632c4 feat(import): improve schema definition 2025-01-19 11:27:14 -03:00
Herculino Trotta
fbb26b8442 feat: rename app, some work on schema 2025-01-17 17:40:51 -03:00
Herculino Trotta
c171e0419a feat: add import app boilerplate 2025-01-16 14:09:33 -03:00
63 changed files with 4767 additions and 237 deletions

View File

@@ -18,3 +18,9 @@ SQL_PORT=5432
# Gunicorn
WEB_CONCURRENCY=4
# App Configs
# Enable this if you want to keep deleted transactions in the database
ENABLE_SOFT_DELETE=false
# If ENABLE_SOFT_DELETE is true, transactions deleted for more than KEEP_DELETED_TRANSACTIONS_FOR days will be truly deleted. Set to 0 to keep all.
KEEP_DELETED_TRANSACTIONS_FOR=365

View File

@@ -64,6 +64,7 @@ INSTALLED_APPS = [
"apps.accounts.apps.AccountsConfig",
"apps.common.apps.CommonConfig",
"apps.net_worth.apps.NetWorthConfig",
"apps.import_app.apps.ImportConfig",
"apps.api.apps.ApiConfig",
"cachalot",
"rest_framework",
@@ -72,6 +73,7 @@ INSTALLED_APPS = [
"apps.rules.apps.RulesConfig",
"apps.calendar_view.apps.CalendarViewConfig",
"apps.dca.apps.DcaConfig",
"pwa",
]
MIDDLEWARE = [
@@ -161,6 +163,7 @@ AUTH_USER_MODEL = "users.User"
LANGUAGE_CODE = "en"
LANGUAGES = (
("en", "English"),
("nl", "Nederlands"),
("pt-br", "Português (Brasil)"),
)
@@ -334,3 +337,46 @@ else:
}
CACHALOT_UNCACHABLE_TABLES = ("django_migrations", "procrastinate_jobs")
# PWA
PWA_APP_NAME = SITE_TITLE
PWA_APP_DESCRIPTION = "A simple and powerful finance tracker"
PWA_APP_THEME_COLOR = "#fbb700"
PWA_APP_BACKGROUND_COLOR = "#222222"
PWA_APP_DISPLAY = "standalone"
PWA_APP_SCOPE = "/"
PWA_APP_ORIENTATION = "any"
PWA_APP_START_URL = "/"
PWA_APP_STATUS_BAR_COLOR = "default"
PWA_APP_ICONS = [
{"src": "/static/img/favicon/android-icon-192x192.png", "sizes": "192x192"}
]
PWA_APP_ICONS_APPLE = [
{"src": "/static/img/favicon/apple-icon-180x180.png", "sizes": "180x180"}
]
PWA_APP_SPLASH_SCREEN = [
{
"src": "/static/img/pwa/splash-640x1136.png",
"media": "(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2)",
}
]
PWA_APP_DIR = "ltr"
PWA_APP_LANG = "en-US"
PWA_APP_SHORTCUTS = []
PWA_APP_SCREENSHOTS = [
{
"src": "/static/img/pwa/splash-750x1334.png",
"sizes": "750x1334",
"type": "image/png",
"form_factor": "wide",
},
{
"src": "/static/img/pwa/splash-750x1334.png",
"sizes": "750x1334",
"type": "image/png",
},
]
ENABLE_SOFT_DELETE = os.getenv("ENABLE_SOFT_DELETE", "false").lower() == "true"
KEEP_DELETED_TRANSACTIONS_FOR = int(os.getenv("KEEP_DELETED_ENTRIES_FOR", "365"))

View File

@@ -27,6 +27,7 @@ urlpatterns = [
path("hijack/", include("hijack.urls")),
path("__debug__/", include("debug_toolbar.urls")),
path("__reload__/", include("django_browser_reload.urls")),
path("", include("pwa.urls")),
# path("api/", include("rest_framework.urls")),
path("api/", include("apps.api.urls")),
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
@@ -47,4 +48,5 @@ urlpatterns = [
path("", include("apps.calendar_view.urls")),
path("", include("apps.dca.urls")),
path("", include("apps.mini_tools.urls")),
path("", include("apps.import_app.urls")),
]

View File

@@ -0,0 +1,38 @@
from django.db import migrations, models
def make_names_unique(apps, schema_editor):
Account = apps.get_model("accounts", "Account")
# Get all accounts ordered by id
accounts = Account.objects.all().order_by("id")
# Track seen names
seen_names = {}
for account in accounts:
original_name = account.name
counter = seen_names.get(original_name, 0)
while account.name in seen_names:
counter += 1
account.name = f"{original_name} ({counter})"
seen_names[account.name] = counter
account.save()
def reverse_migration(apps, schema_editor):
# Can't restore original names, so do nothing
pass
class Migration(migrations.Migration):
dependencies = [
("accounts", "0006_rename_archived_account_is_archived_and_more"),
]
operations = [
migrations.RunPython(make_names_unique, reverse_migration),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.5 on 2025-01-24 00:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0007_make_account_names_unique'),
]
operations = [
migrations.AlterField(
model_name='account',
name='name',
field=models.CharField(max_length=255, unique=True, verbose_name='Name'),
),
]

View File

@@ -18,7 +18,7 @@ class AccountGroup(models.Model):
class Account(models.Model):
name = models.CharField(max_length=255, verbose_name=_("Name"))
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
group = models.ForeignKey(
AccountGroup,
on_delete=models.SET_NULL,

View File

@@ -0,0 +1,11 @@
import json
from django import template
register = template.Library()
@register.filter("json")
def convert_to_json(value):
return json.dumps(value)

View File

@@ -76,12 +76,12 @@ def django_to_airdatepicker_datetime(django_format):
def django_to_airdatepicker_datetime_separated(django_format):
format_map = {
# Time formats
"h": "h", # Hour (12-hour)
"H": "H", # Hour (24-hour)
"i": "m", # Minutes
"h": "hH", # Hour (12-hour)
"H": "HH", # Hour (24-hour)
"i": "mm", # Minutes
"A": "AA", # AM/PM uppercase
"a": "aa", # am/pm lowercase
"P": "h:mm AA", # Localized time format
"P": "h:mm aa", # Localized time format
# Date formats
"D": "E", # Short weekday name
"l": "EEEE", # Full weekday name

View File

@@ -3,6 +3,7 @@ import datetime
from django.forms import widgets
from django.utils import formats, translation, dates
from django.utils.formats import get_format
from django.utils.translation import gettext_lazy as _
from apps.common.utils.django import (
django_to_python_datetime,
@@ -42,7 +43,6 @@ class AirDatePickerInput(widgets.DateInput):
if self.user and hasattr(self.user, "settings"):
user_format = self.user.settings.date_format
print(user_format)
if user_format == "SHORT_DATE_FORMAT":
return get_format("SHORT_DATE_FORMAT", use_l10n=True)
return user_format
@@ -52,6 +52,7 @@ class AirDatePickerInput(widgets.DateInput):
def build_attrs(self, base_attrs, extra_attrs=None):
attrs = super().build_attrs(base_attrs, extra_attrs)
attrs["data-now-button-txt"] = _("Today")
attrs["data-auto-close"] = str(self.auto_close).lower()
attrs["data-clear-button"] = str(self.clear_button).lower()
attrs["data-language"] = self._get_current_language()
@@ -135,6 +136,7 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
)
# Add data attributes for AirDatepicker configuration
attrs["data-now-button-txt"] = _("Now")
attrs["data-timepicker"] = str(self.timepicker).lower()
attrs["data-auto-close"] = str(self.auto_close).lower()
attrs["data-clear-button"] = str(self.clear_button).lower()
@@ -188,6 +190,14 @@ class AirMonthYearPickerInput(AirDatePickerInput):
"""Get month names using Django's date translation"""
return {dates.MONTHS[i]: i for i in range(1, 13)}
def build_attrs(self, base_attrs, extra_attrs=None):
attrs = super().build_attrs(base_attrs, extra_attrs)
# Add data attributes for AirDatepicker configuration
attrs["data-now-button-txt"] = _("Today")
return attrs
def format_value(self, value):
"""Format the value for display in the widget."""
if value:

View File

View File

@@ -0,0 +1,6 @@
from django.contrib import admin
from apps.import_app import models
# Register your models here.
admin.site.register(models.ImportRun)
admin.site.register(models.ImportProfile)

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ImportConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.import_app"

View File

@@ -0,0 +1,64 @@
from crispy_forms.bootstrap import FormActions
from crispy_forms.helper import FormHelper
from crispy_forms.layout import (
Layout,
)
from django import forms
from django.utils.translation import gettext_lazy as _
from apps.import_app.models import ImportProfile
from apps.common.widgets.crispy.submit import NoClassSubmit
class ImportProfileForm(forms.ModelForm):
class Meta:
model = ImportProfile
fields = [
"name",
"version",
"yaml_config",
]
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("name", "version", "yaml_config")
if self.instance and self.instance.pk:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
),
)
else:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
),
)
class ImportRunFileUploadForm(forms.Form):
file = forms.FileField(label=_("Select a file"))
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(
"file",
FormActions(
NoClassSubmit(
"submit", _("Import"), css_class="btn btn-outline-primary w-100"
),
),
)

View File

@@ -0,0 +1,51 @@
# Generated by Django 5.1.5 on 2025-01-19 00:44
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('currencies', '0006_currency_exchange_currency'),
('transactions', '0028_transaction_internal_note'),
]
operations = [
migrations.CreateModel(
name='ImportProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('yaml_config', models.TextField(help_text='YAML configuration')),
('version', models.IntegerField(choices=[(1, 'Version 1')], default=1, verbose_name='Version')),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='ImportRun',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('QUEUED', 'Queued'), ('PROCESSING', 'Processing'), ('FAILED', 'Failed'), ('FINISHED', 'Finished')], default='QUEUED', max_length=10, verbose_name='Status')),
('file_name', models.CharField(help_text='File name', max_length=10000)),
('logs', models.TextField(blank=True)),
('processed_rows', models.IntegerField(default=0)),
('total_rows', models.IntegerField(default=0)),
('successful_rows', models.IntegerField(default=0)),
('skipped_rows', models.IntegerField(default=0)),
('failed_rows', models.IntegerField(default=0)),
('started_at', models.DateTimeField(null=True)),
('finished_at', models.DateTimeField(null=True)),
('categories', models.ManyToManyField(related_name='import_runs', to='transactions.transactioncategory')),
('currencies', models.ManyToManyField(related_name='import_runs', to='currencies.currency')),
('entities', models.ManyToManyField(related_name='import_runs', to='transactions.transactionentity')),
('profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='import_app.importprofile')),
('tags', models.ManyToManyField(related_name='import_runs', to='transactions.transactiontag')),
('transactions', models.ManyToManyField(related_name='import_runs', to='transactions.transaction')),
],
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.1.5 on 2025-01-23 03:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('import_app', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='importprofile',
name='name',
field=models.CharField(max_length=100, unique=True, verbose_name='Name'),
),
migrations.AlterField(
model_name='importprofile',
name='yaml_config',
field=models.TextField(verbose_name='YAML Configuration'),
),
]

View File

@@ -0,0 +1,83 @@
import yaml
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
from apps.import_app.schemas import version_1
class ImportProfile(models.Model):
class Versions(models.IntegerChoices):
VERSION_1 = 1, _("Version") + " 1"
name = models.CharField(max_length=100, verbose_name=_("Name"), unique=True)
yaml_config = models.TextField(verbose_name=_("YAML Configuration"))
version = models.IntegerField(
choices=Versions,
default=Versions.VERSION_1,
verbose_name=_("Version"),
)
def __str__(self):
return self.name
class Meta:
ordering = ["name"]
def clean(self):
if self.version and self.version == self.Versions.VERSION_1:
try:
yaml_data = yaml.safe_load(self.yaml_config)
version_1.ImportProfileSchema(**yaml_data)
except Exception as e:
raise ValidationError(
{"yaml_config": _("Invalid YAML Configuration: ") + str(e)}
)
class ImportRun(models.Model):
class Status(models.TextChoices):
QUEUED = "QUEUED", _("Queued")
PROCESSING = "PROCESSING", _("Processing")
FAILED = "FAILED", _("Failed")
FINISHED = "FINISHED", _("Finished")
status = models.CharField(
max_length=10,
choices=Status,
default=Status.QUEUED,
verbose_name=_("Status"),
)
profile = models.ForeignKey(
ImportProfile,
on_delete=models.CASCADE,
)
file_name = models.CharField(
max_length=10000,
help_text=_("File name"),
)
transactions = models.ManyToManyField(
"transactions.Transaction", related_name="import_runs"
)
tags = models.ManyToManyField(
"transactions.TransactionTag", related_name="import_runs"
)
categories = models.ManyToManyField(
"transactions.TransactionCategory", related_name="import_runs"
)
entities = models.ManyToManyField(
"transactions.TransactionEntity", related_name="import_runs"
)
currencies = models.ManyToManyField(
"currencies.Currency", related_name="import_runs"
)
logs = models.TextField(blank=True)
processed_rows = models.IntegerField(default=0)
total_rows = models.IntegerField(default=0)
successful_rows = models.IntegerField(default=0)
skipped_rows = models.IntegerField(default=0)
failed_rows = models.IntegerField(default=0)
started_at = models.DateTimeField(null=True)
finished_at = models.DateTimeField(null=True)

View File

@@ -0,0 +1 @@
import apps.import_app.schemas.v1 as version_1

View File

@@ -0,0 +1,400 @@
from typing import Dict, List, Optional, Literal
from pydantic import BaseModel, Field, model_validator, field_validator
class CompareDeduplicationRule(BaseModel):
type: Literal["compare"]
fields: list[str] = Field(..., description="Compare fields for deduplication")
match_type: Literal["lax", "strict"] = "lax"
class ReplaceTransformationRule(BaseModel):
type: Literal["replace", "regex"] = Field(
..., description="Type of transformation: replace or regex"
)
pattern: str = Field(..., description="Pattern to match")
replacement: str = Field(..., description="Value to replace with")
exclusive: bool = Field(
default=False,
description="If it should match against the last transformation or the original value",
)
class DateFormatTransformationRule(BaseModel):
type: Literal["date_format"] = Field(
..., description="Type of transformation: date_format"
)
original_format: str = Field(..., description="Original date format")
new_format: str = Field(..., description="New date format to use")
class HashTransformationRule(BaseModel):
fields: List[str]
type: Literal["hash"]
class MergeTransformationRule(BaseModel):
fields: List[str]
type: Literal["merge"]
separator: str = Field(default=" ", description="Separator to use when merging")
class SplitTransformationRule(BaseModel):
type: Literal["split"]
separator: str = Field(default=",", description="Separator to use when splitting")
index: int | None = Field(
default=0, description="Index to return as value. Empty to return all."
)
class CSVImportSettings(BaseModel):
skip_errors: bool = Field(
default=False,
description="If True, errors during import will be logged and skipped",
)
file_type: Literal["csv"] = "csv"
delimiter: str = Field(default=",", description="CSV delimiter character")
encoding: str = Field(default="utf-8", description="File encoding")
skip_lines: int = Field(
default=0, description="Number of rows to skip at the beginning of the file"
)
trigger_transaction_rules: bool = True
importing: Literal[
"transactions", "accounts", "currencies", "categories", "tags", "entities"
]
class ColumnMapping(BaseModel):
source: Optional[str] = Field(
default=None,
description="CSV column header. If None, the field will be generated from transformations",
)
default: Optional[str] = None
required: bool = False
transformations: Optional[
List[
ReplaceTransformationRule
| DateFormatTransformationRule
| HashTransformationRule
| MergeTransformationRule
| SplitTransformationRule
]
] = Field(default_factory=list)
class TransactionAccountMapping(ColumnMapping):
target: Literal["account"] = Field(..., description="Transaction field to map to")
type: Literal["id", "name"] = "name"
coerce_to: Literal["str|int"] = Field("str|int", frozen=True)
required: bool = Field(True, frozen=True)
class TransactionTypeMapping(ColumnMapping):
target: Literal["type"] = Field(..., description="Transaction field to map to")
detection_method: Literal["sign", "always_income", "always_expense"] = "sign"
coerce_to: Literal["transaction_type"] = Field("transaction_type", frozen=True)
class TransactionIsPaidMapping(ColumnMapping):
target: Literal["is_paid"] = Field(..., description="Transaction field to map to")
detection_method: Literal["boolean", "always_paid", "always_unpaid"]
coerce_to: Literal["is_paid"] = Field("is_paid", frozen=True)
class TransactionDateMapping(ColumnMapping):
target: Literal["date"] = Field(..., description="Transaction field to map to")
format: List[str] | str
coerce_to: Literal["date"] = Field("date", frozen=True)
required: bool = Field(True, frozen=True)
class TransactionReferenceDateMapping(ColumnMapping):
target: Literal["reference_date"] = Field(
..., description="Transaction field to map to"
)
format: List[str] | str
coerce_to: Literal["date"] = Field("date", frozen=True)
class TransactionAmountMapping(ColumnMapping):
target: Literal["amount"] = Field(..., description="Transaction field to map to")
coerce_to: Literal["positive_decimal"] = Field("positive_decimal", frozen=True)
required: bool = Field(True, frozen=True)
class TransactionDescriptionMapping(ColumnMapping):
target: Literal["description"] = Field(
..., description="Transaction field to map to"
)
coerce_to: Literal["str"] = Field("str", frozen=True)
class TransactionNotesMapping(ColumnMapping):
target: Literal["notes"] = Field(..., description="Transaction field to map to")
coerce_to: Literal["str"] = Field("str", frozen=True)
class TransactionTagsMapping(ColumnMapping):
target: Literal["tags"] = Field(..., description="Transaction field to map to")
type: Literal["id", "name"] = "name"
create: bool = Field(
default=True, description="Create new tags if they doesn't exist"
)
coerce_to: Literal["list"] = Field("list", frozen=True)
class TransactionEntitiesMapping(ColumnMapping):
target: Literal["entities"] = Field(..., description="Transaction field to map to")
type: Literal["id", "name"] = "name"
create: bool = Field(
default=True, description="Create new entities if they doesn't exist"
)
coerce_to: Literal["list"] = Field("list", frozen=True)
class TransactionCategoryMapping(ColumnMapping):
target: Literal["category"] = Field(..., description="Transaction field to map to")
create: bool = Field(
default=True, description="Create category if it doesn't exist"
)
type: Literal["id", "name"] = "name"
coerce_to: Literal["str|int"] = Field("str|int", frozen=True)
class TransactionInternalNoteMapping(ColumnMapping):
target: Literal["internal_note"] = Field(
..., description="Transaction field to map to"
)
coerce_to: Literal["str"] = Field("str", frozen=True)
class TransactionInternalIDMapping(ColumnMapping):
target: Literal["internal_id"] = Field(
..., description="Transaction field to map to"
)
coerce_to: Literal["str"] = Field("str", frozen=True)
class CategoryNameMapping(ColumnMapping):
target: Literal["category_name"] = Field(
..., description="Category field to map to"
)
coerce_to: Literal["str"] = Field("str", frozen=True)
class CategoryMuteMapping(ColumnMapping):
target: Literal["category_mute"] = Field(
..., description="Category field to map to"
)
coerce_to: Literal["bool"] = Field("bool", frozen=True)
class CategoryActiveMapping(ColumnMapping):
target: Literal["category_active"] = Field(
..., description="Category field to map to"
)
coerce_to: Literal["bool"] = Field("bool", frozen=True)
class TagNameMapping(ColumnMapping):
target: Literal["tag_name"] = Field(..., description="Tag field to map to")
coerce_to: Literal["str"] = Field("str", frozen=True)
class TagActiveMapping(ColumnMapping):
target: Literal["tag_active"] = Field(..., description="Tag field to map to")
coerce_to: Literal["bool"] = Field("bool", frozen=True)
class EntityNameMapping(ColumnMapping):
target: Literal["entity_name"] = Field(..., description="Entity field to map to")
coerce_to: Literal["str"] = Field("str", frozen=True)
class EntityActiveMapping(ColumnMapping):
target: Literal["entity_active"] = Field(..., description="Entity field to map to")
coerce_to: Literal["bool"] = Field("bool", frozen=True)
class AccountNameMapping(ColumnMapping):
target: Literal["account_name"] = Field(..., description="Account field to map to")
coerce_to: Literal["str"] = Field("str", frozen=True)
class AccountGroupMapping(ColumnMapping):
target: Literal["account_group"] = Field(..., description="Account field to map to")
type: Literal["id", "name"]
coerce_to: Literal["str|int"] = Field("str|int", frozen=True)
class AccountCurrencyMapping(ColumnMapping):
target: Literal["account_currency"] = Field(
..., description="Account field to map to"
)
type: Literal["id", "name", "code"]
coerce_to: Literal["str|int"] = Field("str|int", frozen=True)
class AccountExchangeCurrencyMapping(ColumnMapping):
target: Literal["account_exchange_currency"] = Field(
..., description="Account field to map to"
)
type: Literal["id", "name", "code"]
coerce_to: Literal["str|int"] = Field("str|int", frozen=True)
class AccountIsAssetMapping(ColumnMapping):
target: Literal["account_is_asset"] = Field(
..., description="Account field to map to"
)
coerce_to: Literal["bool"] = Field("bool", frozen=True)
class AccountIsArchivedMapping(ColumnMapping):
target: Literal["account_is_archived"] = Field(
..., description="Account field to map to"
)
coerce_to: Literal["bool"] = Field("bool", frozen=True)
class CurrencyCodeMapping(ColumnMapping):
target: Literal["currency_code"] = Field(
..., description="Currency field to map to"
)
coerce_to: Literal["str"] = Field("str", frozen=True)
class CurrencyNameMapping(ColumnMapping):
target: Literal["currency_name"] = Field(
..., description="Currency field to map to"
)
coerce_to: Literal["str"] = Field("str", frozen=True)
class CurrencyDecimalPlacesMapping(ColumnMapping):
target: Literal["currency_decimal_places"] = Field(
..., description="Currency field to map to"
)
coerce_to: Literal["int"] = Field("int", frozen=True)
class CurrencyPrefixMapping(ColumnMapping):
target: Literal["currency_prefix"] = Field(
..., description="Currency field to map to"
)
coerce_to: Literal["str"] = Field("str", frozen=True)
class CurrencySuffixMapping(ColumnMapping):
target: Literal["currency_suffix"] = Field(
..., description="Currency field to map to"
)
coerce_to: Literal["str"] = Field("str", frozen=True)
class CurrencyExchangeMapping(ColumnMapping):
target: Literal["currency_exchange"] = Field(
..., description="Currency field to map to"
)
type: Literal["id", "name", "code"]
coerce_to: Literal["str|int"] = Field("str|int", frozen=True)
class ImportProfileSchema(BaseModel):
settings: CSVImportSettings
mapping: Dict[
str,
TransactionAccountMapping
| TransactionTypeMapping
| TransactionIsPaidMapping
| TransactionDateMapping
| TransactionReferenceDateMapping
| TransactionAmountMapping
| TransactionDescriptionMapping
| TransactionNotesMapping
| TransactionTagsMapping
| TransactionEntitiesMapping
| TransactionCategoryMapping
| TransactionInternalNoteMapping
| TransactionInternalIDMapping
| CategoryNameMapping
| CategoryMuteMapping
| CategoryActiveMapping
| TagNameMapping
| TagActiveMapping
| EntityNameMapping
| EntityActiveMapping
| AccountNameMapping
| AccountGroupMapping
| AccountCurrencyMapping
| AccountExchangeCurrencyMapping
| AccountIsAssetMapping
| AccountIsArchivedMapping
| CurrencyCodeMapping
| CurrencyNameMapping
| CurrencyDecimalPlacesMapping
| CurrencyPrefixMapping
| CurrencySuffixMapping
| CurrencyExchangeMapping,
]
deduplication: List[CompareDeduplicationRule] = Field(
default_factory=list,
description="Rules for deduplicating records during import",
)
@model_validator(mode="after")
def validate_mappings(self) -> "ImportProfileSchema":
import_type = self.settings.importing
# Define allowed mapping types for each import type
allowed_mappings = {
"transactions": (
TransactionAccountMapping,
TransactionTypeMapping,
TransactionIsPaidMapping,
TransactionDateMapping,
TransactionReferenceDateMapping,
TransactionAmountMapping,
TransactionDescriptionMapping,
TransactionNotesMapping,
TransactionTagsMapping,
TransactionEntitiesMapping,
TransactionCategoryMapping,
TransactionInternalNoteMapping,
TransactionInternalIDMapping,
),
"accounts": (
AccountNameMapping,
AccountGroupMapping,
AccountCurrencyMapping,
AccountExchangeCurrencyMapping,
AccountIsAssetMapping,
AccountIsArchivedMapping,
),
"currencies": (
CurrencyCodeMapping,
CurrencyNameMapping,
CurrencyDecimalPlacesMapping,
CurrencyPrefixMapping,
CurrencySuffixMapping,
CurrencyExchangeMapping,
),
"categories": (
CategoryNameMapping,
CategoryMuteMapping,
CategoryActiveMapping,
),
"tags": (TagNameMapping, TagActiveMapping),
"entities": (EntityNameMapping, EntityActiveMapping),
}
allowed_types = allowed_mappings[import_type]
for field_name, mapping in self.mapping.items():
if not isinstance(mapping, allowed_types):
raise ValueError(
f"Mapping type '{type(mapping).__name__}' is not allowed when importing {import_type}. "
f"Allowed types are: {', '.join(t.__name__ for t in allowed_types)}"
)
return self

View File

@@ -0,0 +1,3 @@
from apps.import_app.services.v1 import ImportService as ImportServiceV1
from apps.import_app.services.presets import PresetService

View File

@@ -0,0 +1,45 @@
import json
from pathlib import Path
from apps.import_app.models import ImportProfile
class PresetService:
PRESET_PATH = "/usr/src/app/import_presets"
@classmethod
def get_all_presets(cls):
presets = []
for folder in Path(cls.PRESET_PATH).iterdir():
if folder.is_dir():
manifest_path = folder / "manifest.json"
config_path = folder / "config.yml"
if manifest_path.exists() and config_path.exists():
with open(manifest_path) as f:
manifest = json.load(f)
with open(config_path) as f:
config = json.dumps(f.read())
try:
preset = {
"name": manifest.get("name", folder.name),
"description": manifest.get("description", ""),
"message": json.dumps(manifest.get("message", "")),
"authors": manifest.get("author", "").split(","),
"schema_version": (int(manifest.get("schema_version", 1))),
"folder_name": folder.name,
"config": config,
}
ImportProfile.Versions(
preset["schema_version"]
) # Check if schema version is valid
except Exception as e:
print(e)
else:
presets.append(preset)
return presets

View File

@@ -0,0 +1,633 @@
import csv
import hashlib
import logging
import os
import re
from datetime import datetime
from decimal import Decimal
from typing import Dict, Any, Literal, Union
import cachalot.api
import yaml
from cachalot.api import cachalot_disabled
from django.utils import timezone
from apps.accounts.models import Account, AccountGroup
from apps.currencies.models import Currency
from apps.import_app.models import ImportRun, ImportProfile
from apps.import_app.schemas import version_1
from apps.transactions.models import (
Transaction,
TransactionCategory,
TransactionTag,
TransactionEntity,
)
from apps.rules.signals import transaction_created
from apps.import_app.schemas.v1 import (
TransactionCategoryMapping,
TransactionAccountMapping,
TransactionTagsMapping,
TransactionEntitiesMapping,
)
logger = logging.getLogger(__name__)
class ImportService:
TEMP_DIR = "/usr/src/app/temp"
def __init__(self, import_run: ImportRun):
self.import_run: ImportRun = import_run
self.profile: ImportProfile = import_run.profile
self.config: version_1.ImportProfileSchema = self._load_config()
self.settings: version_1.CSVImportSettings = self.config.settings
self.deduplication: list[version_1.CompareDeduplicationRule] = (
self.config.deduplication
)
self.mapping: Dict[str, version_1.ColumnMapping] = self.config.mapping
# Ensure temp directory exists
os.makedirs(self.TEMP_DIR, exist_ok=True)
def _load_config(self) -> version_1.ImportProfileSchema:
yaml_data = yaml.safe_load(self.profile.yaml_config)
try:
config = version_1.ImportProfileSchema(**yaml_data)
except Exception as e:
self._log("error", f"Fatal error processing YAML config: {str(e)}")
self._update_status("FAILED")
raise e
else:
return config
def _log(self, level: str, message: str, **kwargs) -> None:
"""Add a log entry to the import run logs"""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# Format additional context if present
context = ""
if kwargs:
context = " - " + ", ".join(f"{k}={v}" for k, v in kwargs.items())
log_line = f"[{timestamp}] {level.upper()}: {message}{context}\n"
# Append to existing logs
self.import_run.logs += log_line
self.import_run.save(update_fields=["logs"])
def _update_totals(
self,
field: Literal["total", "processed", "successful", "skipped", "failed"],
value: int,
) -> None:
if field == "total":
self.import_run.total_rows = value
self.import_run.save(update_fields=["total_rows"])
elif field == "processed":
self.import_run.processed_rows = value
self.import_run.save(update_fields=["processed_rows"])
elif field == "successful":
self.import_run.successful_rows = value
self.import_run.save(update_fields=["successful_rows"])
elif field == "skipped":
self.import_run.skipped_rows = value
self.import_run.save(update_fields=["skipped_rows"])
elif field == "failed":
self.import_run.failed_rows = value
self.import_run.save(update_fields=["failed_rows"])
def _increment_totals(
self,
field: Literal["total", "processed", "successful", "skipped", "failed"],
value: int,
) -> None:
if field == "total":
self.import_run.total_rows = self.import_run.total_rows + value
self.import_run.save(update_fields=["total_rows"])
elif field == "processed":
self.import_run.processed_rows = self.import_run.processed_rows + value
self.import_run.save(update_fields=["processed_rows"])
elif field == "successful":
self.import_run.successful_rows = self.import_run.successful_rows + value
self.import_run.save(update_fields=["successful_rows"])
elif field == "skipped":
self.import_run.skipped_rows = self.import_run.skipped_rows + value
self.import_run.save(update_fields=["skipped_rows"])
elif field == "failed":
self.import_run.failed_rows = self.import_run.failed_rows + value
self.import_run.save(update_fields=["failed_rows"])
def _update_status(
self, new_status: Literal["PROCESSING", "FAILED", "FINISHED"]
) -> None:
if new_status == "PROCESSING":
self.import_run.status = ImportRun.Status.PROCESSING
elif new_status == "FAILED":
self.import_run.status = ImportRun.Status.FAILED
elif new_status == "FINISHED":
self.import_run.status = ImportRun.Status.FINISHED
self.import_run.save(update_fields=["status"])
@staticmethod
def _transform_value(
value: str, mapping: version_1.ColumnMapping, row: Dict[str, str] = None
) -> Any:
transformed = value
for transform in mapping.transformations:
if transform.type == "hash":
# Collect all values to be hashed
values_to_hash = []
for field in transform.fields:
if field in row:
values_to_hash.append(str(row[field]))
# Create hash from concatenated values
if values_to_hash:
concatenated = "|".join(values_to_hash)
transformed = hashlib.sha256(concatenated.encode()).hexdigest()
elif transform.type == "replace":
if transform.exclusive:
transformed = value.replace(
transform.pattern, transform.replacement
)
else:
transformed = transformed.replace(
transform.pattern, transform.replacement
)
elif transform.type == "regex":
if transform.exclusive:
transformed = re.sub(
transform.pattern, transform.replacement, value
)
else:
transformed = re.sub(
transform.pattern, transform.replacement, transformed
)
elif transform.type == "date_format":
transformed = datetime.strptime(
transformed, transform.original_format
).strftime(transform.new_format)
elif transform.type == "merge":
values_to_merge = []
for field in transform.fields:
if field in row:
values_to_merge.append(str(row[field]))
transformed = transform.separator.join(values_to_merge)
elif transform.type == "split":
parts = transformed.split(transform.separator)
if transform.index is not None:
transformed = parts[transform.index] if parts else ""
else:
transformed = parts
return transformed
def _create_transaction(self, data: Dict[str, Any]) -> Transaction:
tags = []
entities = []
# Handle related objects first
if "category" in data:
if "category" in data:
category_name = data.pop("category")
category_mapping = next(
(
m
for m in self.mapping.values()
if isinstance(m, TransactionCategoryMapping)
and m.target == "category"
),
None,
)
print(category_mapping)
try:
if category_mapping:
if category_mapping.type == "id":
category = TransactionCategory.objects.get(id=category_name)
else: # name
if getattr(category_mapping, "create", False):
category, _ = TransactionCategory.objects.get_or_create(
name=category_name
)
else:
category = TransactionCategory.objects.filter(
name=category_name
).first()
if category:
data["category"] = category
self.import_run.categories.add(category)
except (TransactionCategory.DoesNotExist, ValueError):
# Ignore if category doesn't exist and create is False or not set
data["category"] = None
if "account" in data:
account_id = data.pop("account")
account_mapping = next(
(
m
for m in self.mapping.values()
if isinstance(m, TransactionAccountMapping)
and m.target == "account"
),
None,
)
try:
if account_mapping and account_mapping.type == "id":
account = Account.objects.filter(id=account_id).first()
else: # name
account = Account.objects.filter(name=account_id).first()
if account:
data["account"] = account
except ValueError:
# Ignore if account doesn't exist
pass
if "tags" in data:
tag_names = data.pop("tags")
tags_mapping = next(
(
m
for m in self.mapping.values()
if isinstance(m, TransactionTagsMapping) and m.target == "tags"
),
None,
)
for tag_name in tag_names:
try:
if tags_mapping:
if tags_mapping.type == "id":
tag = TransactionTag.objects.filter(id=tag_name).first()
else: # name
if getattr(tags_mapping, "create", False):
tag, _ = TransactionTag.objects.get_or_create(
name=tag_name.strip()
)
else:
tag = TransactionTag.objects.filter(
name=tag_name.strip()
).first()
if tag:
tags.append(tag)
self.import_run.tags.add(tag)
except ValueError:
# Ignore if tag doesn't exist and create is False or not set
continue
if "entities" in data:
entity_names = data.pop("entities")
entities_mapping = next(
(
m
for m in self.mapping.values()
if isinstance(m, TransactionEntitiesMapping)
and m.target == "entities"
),
None,
)
for entity_name in entity_names:
try:
if entities_mapping:
if entities_mapping.type == "id":
entity = TransactionTag.objects.filter(
id=entity_name
).first()
else: # name
if getattr(entities_mapping, "create", False):
entity, _ = TransactionEntity.objects.get_or_create(
name=entity_name.strip()
)
else:
entity = TransactionEntity.objects.filter(
name=entity_name.strip()
).first()
if entity:
entities.append(entity)
self.import_run.entities.add(entity)
except ValueError:
# Ignore if entity doesn't exist and create is False or not set
continue
# Create the transaction
new_transaction = Transaction.objects.create(**data)
self.import_run.transactions.add(new_transaction)
# Add many-to-many relationships
if tags:
new_transaction.tags.set(tags)
if entities:
new_transaction.entities.set(entities)
if self.settings.trigger_transaction_rules:
transaction_created.send(sender=new_transaction)
return new_transaction
def _create_account(self, data: Dict[str, Any]) -> Account:
if "group" in data:
group_name = data.pop("group")
group, _ = AccountGroup.objects.get_or_create(name=group_name)
data["group"] = group
# Handle currency references
if "currency" in data:
currency = Currency.objects.get(code=data["currency"])
data["currency"] = currency
self.import_run.currencies.add(currency)
if "exchange_currency" in data:
exchange_currency = Currency.objects.get(code=data["exchange_currency"])
data["exchange_currency"] = exchange_currency
self.import_run.currencies.add(exchange_currency)
return Account.objects.create(**data)
def _create_currency(self, data: Dict[str, Any]) -> Currency:
# Handle exchange currency reference
if "exchange_currency" in data:
exchange_currency = Currency.objects.get(code=data["exchange_currency"])
data["exchange_currency"] = exchange_currency
self.import_run.currencies.add(exchange_currency)
currency = Currency.objects.create(**data)
self.import_run.currencies.add(currency)
return currency
def _create_category(self, data: Dict[str, Any]) -> TransactionCategory:
category = TransactionCategory.objects.create(**data)
self.import_run.categories.add(category)
return category
def _create_tag(self, data: Dict[str, Any]) -> TransactionTag:
tag = TransactionTag.objects.create(**data)
self.import_run.tags.add(tag)
return tag
def _create_entity(self, data: Dict[str, Any]) -> TransactionEntity:
entity = TransactionEntity.objects.create(**data)
self.import_run.entities.add(entity)
return entity
def _check_duplicate_transaction(self, transaction_data: Dict[str, Any]) -> bool:
for rule in self.deduplication:
if rule.type == "compare":
query = Transaction.all_objects.all().values("id")
# Build query conditions for each field in the rule
for field in rule.fields:
if field in transaction_data:
if rule.match_type == "strict":
query = query.filter(**{field: transaction_data[field]})
else: # lax matching
query = query.filter(
**{f"{field}__iexact": transaction_data[field]}
)
# If we found any matching transaction, it's a duplicate
if query.exists():
return True
return False
def _coerce_type(
self, value: str, mapping: version_1.ColumnMapping
) -> Union[str, int, bool, Decimal, datetime, list]:
if not value:
return None
coerce_to = mapping.coerce_to
return self._coerce_single_type(value, coerce_to, mapping)
@staticmethod
def _coerce_single_type(
value: str, coerce_to: str, mapping: version_1.ColumnMapping
) -> Union[str, int, bool, Decimal, datetime.date, list]:
if coerce_to == "str":
return str(value)
elif coerce_to == "int":
return int(value)
elif coerce_to == "str|int":
if hasattr(mapping, "type") and mapping.type == "id":
return int(value)
elif hasattr(mapping, "type") and mapping.type in ["name", "code"]:
return str(value)
else:
return str(value)
elif coerce_to == "bool":
return value.lower() in ["true", "1", "yes", "y", "on"]
elif coerce_to == "positive_decimal":
return abs(Decimal(value))
elif coerce_to == "date":
if isinstance(
mapping,
(
version_1.TransactionDateMapping,
version_1.TransactionReferenceDateMapping,
),
):
formats = (
mapping.format
if isinstance(mapping.format, list)
else [mapping.format]
)
for fmt in formats:
try:
return datetime.strptime(value, fmt).date()
except ValueError:
continue
raise ValueError(
f"Could not parse date '{value}' with any of the provided formats"
)
else:
raise ValueError(
"Date coercion is only supported for TransactionDateMapping and TransactionReferenceDateMapping"
)
elif coerce_to == "list":
return (
value
if isinstance(value, list)
else [item.strip() for item in value.split(",") if item.strip()]
)
elif coerce_to == "transaction_type":
if isinstance(mapping, version_1.TransactionTypeMapping):
if mapping.detection_method == "sign":
return (
Transaction.Type.EXPENSE
if value.startswith("-")
else Transaction.Type.INCOME
)
elif mapping.detection_method == "always_income":
return Transaction.Type.INCOME
elif mapping.detection_method == "always_expense":
return Transaction.Type.EXPENSE
raise ValueError("Invalid transaction type detection method")
elif coerce_to == "is_paid":
if isinstance(mapping, version_1.TransactionIsPaidMapping):
if mapping.detection_method == "boolean":
return value.lower() in ["true", "1", "yes", "y", "on"]
elif mapping.detection_method == "always_paid":
return True
elif mapping.detection_method == "always_unpaid":
return False
raise ValueError("Invalid is_paid detection method")
else:
raise ValueError(f"Unsupported coercion type: {coerce_to}")
def _map_row(self, row: Dict[str, str]) -> Dict[str, Any]:
mapped_data = {}
for field, mapping in self.mapping.items():
# If source is None, use None as the initial value
value = row.get(mapping.source) if mapping.source else None
# Use default_value if value is None
if value is None:
value = mapping.default
# Apply transformations
if mapping.transformations:
value = self._transform_value(value, mapping, row)
value = self._coerce_type(value, mapping)
if mapping.required and value is None:
raise ValueError(f"Required field {field} is missing")
if value is not None:
# Remove the prefix from the target field
target = mapping.target
if self.settings.importing == "transactions":
mapped_data[target] = value
else:
# Remove the model prefix (e.g., "account_" from "account_name")
field_name = target.split("_", 1)[1]
mapped_data[field_name] = value
return mapped_data
def _process_row(self, row: Dict[str, str], row_number: int) -> None:
try:
mapped_data = self._map_row(row)
if mapped_data:
# Handle different import types
if self.settings.importing == "transactions":
if self.deduplication and self._check_duplicate_transaction(
mapped_data
):
self._increment_totals("skipped", 1)
self._log("info", f"Skipped duplicate row {row_number}")
return
self._create_transaction(mapped_data)
elif self.settings.importing == "accounts":
self._create_account(mapped_data)
elif self.settings.importing == "currencies":
self._create_currency(mapped_data)
elif self.settings.importing == "categories":
self._create_category(mapped_data)
elif self.settings.importing == "tags":
self._create_tag(mapped_data)
elif self.settings.importing == "entities":
self._create_entity(mapped_data)
self._increment_totals("successful", value=1)
self._log("info", f"Successfully processed row {row_number}")
self._increment_totals("processed", value=1)
except Exception as e:
if not self.settings.skip_errors:
self._log("error", f"Fatal error processing row {row_number}: {str(e)}")
self._update_status("FAILED")
raise
else:
self._log("warning", f"Error processing row {row_number}: {str(e)}")
self._increment_totals("failed", value=1)
logger.error(f"Fatal error processing row {row_number}", exc_info=e)
def _process_csv(self, file_path):
# First pass: count rows
with open(file_path, "r", encoding=self.settings.encoding) as csv_file:
# Skip specified number of rows
for _ in range(self.settings.skip_lines):
next(csv_file)
reader = csv.DictReader(csv_file, delimiter=self.settings.delimiter)
self._update_totals("total", value=sum(1 for _ in reader))
with open(file_path, "r", encoding=self.settings.encoding) as csv_file:
# Skip specified number of rows
for _ in range(self.settings.skip_lines):
next(csv_file)
if self.settings.skip_lines:
self._log("info", f"Skipped {self.settings.skip_lines} initial lines")
reader = csv.DictReader(csv_file, delimiter=self.settings.delimiter)
self._log("info", f"Starting import with {self.import_run.total_rows} rows")
for row_number, row in enumerate(reader, start=1):
self._process_row(row, row_number)
def _validate_file_path(self, file_path: str) -> str:
"""
Validates that the file path is within the allowed temporary directory.
Returns the absolute path.
"""
abs_path = os.path.abspath(file_path)
if not abs_path.startswith(self.TEMP_DIR):
raise ValueError(f"Invalid file path. File must be in {self.TEMP_DIR}")
return abs_path
def process_file(self, file_path: str):
with cachalot_disabled():
# Validate and get absolute path
file_path = self._validate_file_path(file_path)
self._update_status("PROCESSING")
self.import_run.started_at = timezone.now()
self.import_run.save(update_fields=["started_at"])
self._log("info", "Starting import process")
try:
if self.settings.file_type == "csv":
self._process_csv(file_path)
self._update_status("FINISHED")
self._log(
"info",
f"Import completed successfully. "
f"Successful: {self.import_run.successful_rows}, "
f"Failed: {self.import_run.failed_rows}, "
f"Skipped: {self.import_run.skipped_rows}",
)
except Exception as e:
self._update_status("FAILED")
self._log("error", f"Import failed: {str(e)}")
raise Exception("Import failed")
finally:
self._log("info", "Cleaning up temporary files")
try:
if os.path.exists(file_path):
os.remove(file_path)
self._log("info", f"Deleted temporary file: {file_path}")
except OSError as e:
self._log("warning", f"Failed to delete temporary file: {str(e)}")
self.import_run.finished_at = timezone.now()
self.import_run.save(update_fields=["finished_at"])
cachalot.api.invalidate()

View File

@@ -0,0 +1,21 @@
import logging
import cachalot.api
from procrastinate.contrib.django import app
from apps.import_app.models import ImportRun
from apps.import_app.services import ImportServiceV1
logger = logging.getLogger(__name__)
@app.task
def process_import(import_run_id: int, file_path: str):
try:
import_run = ImportRun.objects.get(id=import_run_id)
import_service = ImportServiceV1(import_run)
import_service.process_file(file_path)
cachalot.api.invalidate()
except ImportRun.DoesNotExist:
cachalot.api.invalidate()
raise ValueError(f"ImportRun with id {import_run_id} not found")

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,56 @@
from django.urls import path
import apps.import_app.views as views
urlpatterns = [
path("import/", views.import_view, name="import"),
path(
"import/presets/",
views.import_presets_list,
name="import_presets_list",
),
path(
"import/profiles/",
views.import_profile_index,
name="import_profiles_index",
),
path(
"import/profiles/list/",
views.import_profile_list,
name="import_profiles_list",
),
path(
"import/profiles/<int:profile_id>/delete/",
views.import_profile_delete,
name="import_profile_delete",
),
path(
"import/profiles/add/",
views.import_profile_add,
name="import_profiles_add",
),
path(
"import/profiles/<int:profile_id>/edit/",
views.import_profile_edit,
name="import_profile_edit",
),
path(
"import/profiles/<int:profile_id>/runs/list/",
views.import_runs_list,
name="import_profile_runs_list",
),
path(
"import/profiles/<int:profile_id>/runs/<int:run_id>/log/",
views.import_run_log,
name="import_run_log",
),
path(
"import/profiles/<int:profile_id>/runs/<int:run_id>/delete/",
views.import_run_delete,
name="import_run_delete",
),
path(
"import/profiles/<int:profile_id>/runs/add/",
views.import_run_add,
name="import_run_add",
),
]

View File

@@ -0,0 +1,232 @@
import shutil
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.files.storage import FileSystemStorage
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from django.utils.translation import gettext_lazy as _
from apps.common.decorators.htmx import only_htmx
from apps.import_app.forms import ImportRunFileUploadForm, ImportProfileForm
from apps.import_app.models import ImportRun, ImportProfile
from apps.import_app.tasks import process_import
from apps.import_app.services import PresetService
def import_view(request):
import_profile = ImportProfile.objects.get(id=2)
shutil.copyfile(
"/usr/src/app/apps/import_app/teste2.csv", "/usr/src/app/temp/teste2.csv"
)
ir = ImportRun.objects.create(profile=import_profile, file_name="teste.csv")
process_import.defer(
import_run_id=ir.id,
file_path="/usr/src/app/temp/teste2.csv",
)
return HttpResponse("Hello, world. You're at the polls page.")
@login_required
@require_http_methods(["GET"])
def import_presets_list(request):
presets = PresetService.get_all_presets()
print(presets)
return render(
request,
"import_app/fragments/profiles/list_presets.html",
{"presets": presets},
)
@login_required
@require_http_methods(["GET", "POST"])
def import_profile_index(request):
return render(
request,
"import_app/pages/profiles_index.html",
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def import_profile_list(request):
profiles = ImportProfile.objects.all()
return render(
request,
"import_app/fragments/profiles/list.html",
{"profiles": profiles},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def import_profile_add(request):
message = request.GET.get("message", None) or request.POST.get("message", None)
if request.method == "POST":
form = ImportProfileForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, _("Import Profile added successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
else:
print(int(request.GET.get("version", 1)))
form = ImportProfileForm(
initial={
"name": request.GET.get("name"),
"version": int(request.GET.get("version", 1)),
"yaml_config": request.GET.get("yaml_config"),
}
)
return render(
request,
"import_app/fragments/profiles/add.html",
{"form": form, "message": message},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def import_profile_edit(request, profile_id):
profile = get_object_or_404(ImportProfile, id=profile_id)
if request.method == "POST":
form = ImportProfileForm(request.POST, instance=profile)
if form.is_valid():
form.save()
messages.success(request, _("Import Profile update successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
else:
form = ImportProfileForm(instance=profile)
return render(
request,
"import_app/fragments/profiles/edit.html",
{"form": form, "profile": profile},
)
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def import_profile_delete(request, profile_id):
profile = ImportProfile.objects.get(id=profile_id)
profile.delete()
messages.success(request, _("Import Profile deleted successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated",
},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def import_runs_list(request, profile_id):
profile = ImportProfile.objects.get(id=profile_id)
runs = ImportRun.objects.filter(profile=profile).order_by("-id")
return render(
request,
"import_app/fragments/runs/list.html",
{"profile": profile, "runs": runs},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def import_run_log(request, profile_id, run_id):
run = ImportRun.objects.get(profile__id=profile_id, id=run_id)
return render(
request,
"import_app/fragments/runs/log.html",
{"run": run},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def import_run_add(request, profile_id):
profile = ImportProfile.objects.get(id=profile_id)
if request.method == "POST":
form = ImportRunFileUploadForm(request.POST, request.FILES)
if form.is_valid():
uploaded_file = request.FILES["file"]
fs = FileSystemStorage(location="/usr/src/app/temp")
filename = fs.save(uploaded_file.name, uploaded_file)
file_path = fs.path(filename)
import_run = ImportRun.objects.create(profile=profile, file_name=filename)
# Defer the procrastinate task
process_import.defer(import_run_id=import_run.id, file_path=file_path)
messages.success(request, _("Import Run queued successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas",
},
)
else:
form = ImportRunFileUploadForm()
return render(
request,
"import_app/fragments/runs/add.html",
{"form": form, "profile": profile},
)
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def import_run_delete(request, profile_id, run_id):
run = ImportRun.objects.get(profile__id=profile_id, id=run_id)
run.delete()
messages.success(request, _("Run deleted successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated",
},
)

View File

@@ -12,15 +12,34 @@ from apps.transactions.models import (
@admin.register(Transaction)
class TransactionModelAdmin(admin.ModelAdmin):
def get_queryset(self, request):
# Use the all_objects manager to show all transactions, including deleted ones
return self.model.all_objects.all()
list_filter = ["deleted", "type", "is_paid", "date", "account"]
list_display = [
"date",
"description",
"type",
"account__name",
"amount",
"account__currency__code",
"date",
"reference_date",
"deleted",
]
readonly_fields = ["deleted_at"]
actions = ["hard_delete_selected"]
def hard_delete_selected(self, request, queryset):
for obj in queryset:
obj.hard_delete()
self.message_user(
request, f"Successfully hard deleted {queryset.count()} transactions."
)
hard_delete_selected.short_description = "Hard delete selected transactions"
class TransactionInline(admin.TabularInline):

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.5 on 2025-01-19 00:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0027_alter_transaction_description'),
]
operations = [
migrations.AddField(
model_name='transaction',
name='internal_note',
field=models.TextField(blank=True, verbose_name='Internal Note'),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.1.5 on 2025-01-19 14:59
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('transactions', '0028_transaction_internal_note'),
]
operations = [
migrations.AlterModelOptions(
name='transaction',
options={'default_manager_name': 'objects', 'verbose_name': 'Transaction', 'verbose_name_plural': 'Transactions'},
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.1.5 on 2025-01-19 14:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0029_alter_transaction_options'),
]
operations = [
migrations.AddField(
model_name='transaction',
name='deleted',
field=models.BooleanField(default=False, verbose_name='Deleted'),
),
migrations.AddField(
model_name='transaction',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Deleted At'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.5 on 2025-01-19 15:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0030_transaction_deleted_transaction_deleted_at'),
]
operations = [
migrations.AlterField(
model_name='transaction',
name='deleted',
field=models.BooleanField(db_index=True, default=False, verbose_name='Deleted'),
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 5.1.5 on 2025-01-19 16:48
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0031_alter_transaction_deleted'),
]
operations = [
migrations.AddField(
model_name='transaction',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='transaction',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 5.1.5 on 2025-01-21 01:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("transactions", "0032_transaction_created_at_transaction_updated_at"),
]
operations = [
migrations.AddField(
model_name="transaction",
name="internal_id",
field=models.TextField(
blank=True, null=True, unique=True, verbose_name="Internal ID"
),
),
]

View File

@@ -6,6 +6,7 @@ from django.db import models, transaction
from django.db.models import Q
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.conf import settings
from apps.common.fields.month_year import MonthYearModelField
from apps.common.functions.decimals import truncate_decimal
@@ -15,6 +16,53 @@ from apps.transactions.validators import validate_decimal_places, validate_non_n
logger = logging.getLogger()
class SoftDeleteQuerySet(models.QuerySet):
def delete(self):
if not settings.ENABLE_SOFT_DELETE:
# If soft deletion is disabled, perform a normal delete
return super().delete()
# Separate the queryset into already deleted and not deleted objects
already_deleted = self.filter(deleted=True)
not_deleted = self.filter(deleted=False)
# Use a transaction to ensure atomicity
with transaction.atomic():
# Perform hard delete on already deleted objects
hard_deleted_count = already_deleted._raw_delete(already_deleted.db)
# Perform soft delete on not deleted objects
soft_deleted_count = not_deleted.update(
deleted=True, deleted_at=timezone.now()
)
# Return a tuple of counts as expected by Django's delete method
return (
hard_deleted_count + soft_deleted_count,
{"Transaction": hard_deleted_count + soft_deleted_count},
)
def hard_delete(self):
return super().delete()
class SoftDeleteManager(models.Manager):
def get_queryset(self):
qs = SoftDeleteQuerySet(self.model, using=self._db)
return qs if not settings.ENABLE_SOFT_DELETE else qs.filter(deleted=False)
class AllObjectsManager(models.Manager):
def get_queryset(self):
return SoftDeleteQuerySet(self.model, using=self._db)
class DeletedObjectsManager(models.Manager):
def get_queryset(self):
qs = SoftDeleteQuerySet(self.model, using=self._db)
return qs if not settings.ENABLE_SOFT_DELETE else qs.filter(deleted=True)
class TransactionCategory(models.Model):
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
mute = models.BooleanField(default=False, verbose_name=_("Mute"))
@@ -141,11 +189,29 @@ class Transaction(models.Model):
related_name="transactions",
verbose_name=_("Recurring Transaction"),
)
internal_note = models.TextField(blank=True, verbose_name=_("Internal Note"))
internal_id = models.TextField(
blank=True, null=True, unique=True, verbose_name=_("Internal ID")
)
deleted = models.BooleanField(
default=False, verbose_name=_("Deleted"), db_index=True
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
deleted_at = models.DateTimeField(
null=True, blank=True, verbose_name=_("Deleted At")
)
objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)()
all_objects = AllObjectsManager.from_queryset(SoftDeleteQuerySet)()
deleted_objects = DeletedObjectsManager.from_queryset(SoftDeleteQuerySet)()
class Meta:
verbose_name = _("Transaction")
verbose_name_plural = _("Transactions")
db_table = "transactions"
default_manager_name = "objects"
def save(self, *args, **kwargs):
self.amount = truncate_decimal(
@@ -160,6 +226,17 @@ class Transaction(models.Model):
self.full_clean()
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
if settings.ENABLE_SOFT_DELETE:
self.deleted = True
self.deleted_at = timezone.now()
self.save()
else:
super().delete(*args, **kwargs)
def hard_delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
def exchanged_amount(self):
if self.account.exchange_currency:
converted_amount, prefix, suffix, decimal_places = convert(
@@ -178,6 +255,10 @@ class Transaction(models.Model):
return None
def __str__(self):
type_display = self.get_type_display()
return f"{self.description} - {type_display} - {self.account} - {self.date}"
class InstallmentPlan(models.Model):
class Recurrence(models.TextChoices):

View File

@@ -1,9 +1,13 @@
import logging
from datetime import timedelta
from cachalot.api import cachalot_disabled, invalidate
from django.utils import timezone
from django.conf import settings
from procrastinate.contrib.django import app
from apps.transactions.models import RecurringTransaction
from apps.transactions.models import RecurringTransaction, Transaction
logger = logging.getLogger(__name__)
@@ -19,3 +23,31 @@ def generate_recurring_transactions(timestamp=None):
exc_info=True,
)
raise e
@app.periodic(cron="10 1 * * *")
@app.task
def cleanup_deleted_transactions():
with cachalot_disabled():
if settings.ENABLE_SOFT_DELETE and settings.KEEP_DELETED_TRANSACTIONS_FOR == 0:
return "KEEP_DELETED_TRANSACTIONS_FOR is 0, no cleanup performed."
if not settings.ENABLE_SOFT_DELETE:
# Hard delete all soft-deleted transactions
deleted_count, _ = Transaction.deleted_objects.all().hard_delete()
return (
f"Hard deleted {deleted_count} transactions (soft deletion disabled)."
)
# Calculate the cutoff date
cutoff_date = timezone.now() - timedelta(
days=settings.KEEP_DELETED_TRANSACTIONS_FOR
)
invalidate("transactions.Transaction")
# Hard delete soft-deleted transactions older than the cutoff date
old_transactions = Transaction.deleted_objects.filter(deleted_at__lt=cutoff_date)
deleted_count, _ = old_transactions.hard_delete()
return f"Hard deleted {deleted_count} objects older than {settings.KEEP_DELETED_TRANSACTIONS_FOR} days."

View File

@@ -82,10 +82,12 @@ class UserSettingsForm(forms.ModelForm):
]
date_format = forms.ChoiceField(
choices=DATE_FORMAT_CHOICES, initial="SHORT_DATE_FORMAT"
choices=DATE_FORMAT_CHOICES, initial="SHORT_DATE_FORMAT", label=_("Date Format")
)
datetime_format = forms.ChoiceField(
choices=DATETIME_FORMAT_CHOICES, initial="SHORT_DATETIME_FORMAT"
choices=DATETIME_FORMAT_CHOICES,
initial="SHORT_DATETIME_FORMAT",
label=_("Datetime Format"),
)
class Meta:

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.1.5 on 2025-01-23 03:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0013_usersettings_date_format_and_more'),
]
operations = [
migrations.AlterField(
model_name='usersettings',
name='date_format',
field=models.CharField(default='SHORT_DATE_FORMAT', max_length=100, verbose_name='Date Format'),
),
migrations.AlterField(
model_name='usersettings',
name='datetime_format',
field=models.CharField(default='SHORT_DATETIME_FORMAT', max_length=100, verbose_name='Datetime Format'),
),
migrations.AlterField(
model_name='usersettings',
name='language',
field=models.CharField(choices=[('auto', 'Auto'), ('en', 'English'), ('nl', 'Nederlands'), ('pt-br', 'Português (Brasil)')], default='auto', max_length=10, verbose_name='Language'),
),
]

View File

@@ -36,8 +36,14 @@ class UserSettings(models.Model):
hide_amounts = models.BooleanField(default=False)
mute_sounds = models.BooleanField(default=False)
date_format = models.CharField(max_length=100, default="SHORT_DATE_FORMAT")
datetime_format = models.CharField(max_length=100, default="SHORT_DATETIME_FORMAT")
date_format = models.CharField(
max_length=100, default="SHORT_DATE_FORMAT", verbose_name=_("Date Format")
)
datetime_format = models.CharField(
max_length=100,
default="SHORT_DATETIME_FORMAT",
verbose_name=_("Datetime Format"),
)
language = models.CharField(
max_length=10,

View File

View File

@@ -0,0 +1,54 @@
settings:
file_type: csv
delimiter: ","
encoding: utf-8
skip_lines: 0
importing: transactions
trigger_transaction_rules: true
skip_errors: true
mapping:
account:
target: account
default: <NOME DA SUA CONTA>
type: name
date:
target: date
source: Data
format: "%d/%m/%Y"
amount:
target: amount
source: Valor
description:
target: description
source: Descrição
transformations:
- type: split
separator: " - "
index: 0
type:
source: "Valor"
target: "type"
detection_method: sign
notes:
target: notes
source: Notes
internal_id:
target: internal_id
source: Identificador
is_paid:
target: is_paid
detection_method: always_paid
deduplicate:
- type: compare
fields:
- internal_id
match_type: lax

View File

@@ -0,0 +1,7 @@
{
"author": "eitchtee",
"description": "Importe suas transações da conta corrente do Nubank",
"schema_version": 1,
"name": "Nubank - Conta Corrente",
"message": "Mude '<NOME DA SUA CONTA>' para o nome da sua Nuconta dentro do WYGIWYH"
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,8 +8,8 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-11 16:40+0000\n"
"PO-Revision-Date: 2025-01-11 13:41-0300\n"
"POT-Creation-Date: 2025-01-21 01:12+0000\n"
"PO-Revision-Date: 2025-01-20 22:12-0300\n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: pt_BR\n"
@@ -24,29 +24,30 @@ msgid "Group name"
msgstr "Nome do grupo"
#: apps/accounts/forms.py:40 apps/accounts/forms.py:96
#: apps/currencies/forms.py:51 apps/currencies/forms.py:90 apps/dca/forms.py:40
#: apps/currencies/forms.py:52 apps/currencies/forms.py:92 apps/dca/forms.py:41
#: apps/dca/forms.py:93 apps/rules/forms.py:45 apps/rules/forms.py:87
#: apps/transactions/forms.py:145 apps/transactions/forms.py:495
#: apps/transactions/forms.py:538 apps/transactions/forms.py:570
#: apps/transactions/forms.py:605 apps/transactions/forms.py:741
#: apps/transactions/forms.py:150 apps/transactions/forms.py:506
#: apps/transactions/forms.py:549 apps/transactions/forms.py:581
#: apps/transactions/forms.py:616 apps/transactions/forms.py:754
msgid "Update"
msgstr "Atualizar"
#: apps/accounts/forms.py:48 apps/accounts/forms.py:104
#: apps/common/widgets/tom_select.py:12 apps/currencies/forms.py:59
#: apps/currencies/forms.py:98 apps/dca/forms.py:48 apps/dca/forms.py:102
#: apps/rules/forms.py:53 apps/rules/forms.py:95 apps/transactions/forms.py:154
#: apps/transactions/forms.py:503 apps/transactions/forms.py:546
#: apps/transactions/forms.py:578 apps/transactions/forms.py:613
#: apps/transactions/forms.py:749
#: apps/common/widgets/tom_select.py:12 apps/currencies/forms.py:60
#: apps/currencies/forms.py:100 apps/dca/forms.py:49 apps/dca/forms.py:102
#: apps/rules/forms.py:53 apps/rules/forms.py:95 apps/transactions/forms.py:159
#: apps/transactions/forms.py:514 apps/transactions/forms.py:557
#: apps/transactions/forms.py:589 apps/transactions/forms.py:624
#: apps/transactions/forms.py:762
#: templates/account_groups/fragments/list.html:9
#: templates/accounts/fragments/list.html:9
#: templates/categories/fragments/list.html:9
#: templates/currencies/fragments/list.html:9
#: templates/dca/fragments/strategy/details.html:37
#: templates/dca/fragments/strategy/details.html:38
#: templates/dca/fragments/strategy/list.html:9
#: templates/entities/fragments/list.html:9
#: templates/exchange_rates/fragments/list.html:10
#: templates/import_app/fragments/list.html:9
#: templates/installment_plans/fragments/list.html:9
#: templates/mini_tools/unit_price_calculator.html:162
#: templates/recurring_transactions/fragments/list.html:9
@@ -55,6 +56,7 @@ msgid "Add"
msgstr "Adicionar"
#: apps/accounts/forms.py:57 templates/accounts/fragments/list.html:26
#: templates/import_app/fragments/list.html:26
msgid "Group"
msgstr "Grupo da Conta"
@@ -63,19 +65,19 @@ msgid "New balance"
msgstr "Novo saldo"
#: apps/accounts/forms.py:119 apps/rules/models.py:27
#: apps/transactions/forms.py:39 apps/transactions/forms.py:209
#: apps/transactions/forms.py:216 apps/transactions/forms.py:395
#: apps/transactions/forms.py:637 apps/transactions/models.py:109
#: apps/transactions/models.py:228 apps/transactions/models.py:408
#: apps/transactions/forms.py:39 apps/transactions/forms.py:214
#: apps/transactions/forms.py:221 apps/transactions/forms.py:401
#: apps/transactions/forms.py:648 apps/transactions/models.py:111
#: apps/transactions/models.py:230 apps/transactions/models.py:410
msgid "Category"
msgstr "Categoria"
#: apps/accounts/forms.py:126 apps/rules/models.py:28
#: apps/transactions/filters.py:73 apps/transactions/forms.py:47
#: apps/transactions/forms.py:225 apps/transactions/forms.py:233
#: apps/transactions/forms.py:388 apps/transactions/forms.py:630
#: apps/transactions/models.py:115 apps/transactions/models.py:230
#: apps/transactions/models.py:412 templates/includes/navbar.html:98
#: apps/transactions/filters.py:74 apps/transactions/forms.py:47
#: apps/transactions/forms.py:230 apps/transactions/forms.py:238
#: apps/transactions/forms.py:394 apps/transactions/forms.py:641
#: apps/transactions/models.py:117 apps/transactions/models.py:232
#: apps/transactions/models.py:414 templates/includes/navbar.html:98
#: templates/tags/fragments/list.html:5 templates/tags/pages/index.html:4
msgid "Tags"
msgstr "Tags"
@@ -88,6 +90,7 @@ msgstr "Tags"
#: templates/categories/fragments/table.html:16
#: templates/currencies/fragments/list.html:26
#: templates/entities/fragments/table.html:16
#: templates/import_app/fragments/list.html:25
#: templates/installment_plans/fragments/table.html:16
#: templates/recurring_transactions/fragments/table.html:18
#: templates/rules/fragments/list.html:26
@@ -107,11 +110,13 @@ msgstr "Grupos da Conta"
#: apps/accounts/models.py:31 apps/currencies/models.py:32
#: templates/accounts/fragments/list.html:27
#: templates/import_app/fragments/list.html:27
msgid "Currency"
msgstr "Moeda"
#: apps/accounts/models.py:37 apps/currencies/models.py:20
#: templates/accounts/fragments/list.html:28
#: templates/import_app/fragments/list.html:28
msgid "Exchange Currency"
msgstr "Moeda de Câmbio"
@@ -133,6 +138,7 @@ msgstr ""
#: apps/accounts/models.py:54 templates/accounts/fragments/list.html:30
#: templates/categories/fragments/list.html:24
#: templates/entities/fragments/list.html:24
#: templates/import_app/fragments/list.html:30
#: templates/tags/fragments/list.html:24
msgid "Archived"
msgstr "Arquivada"
@@ -143,16 +149,17 @@ msgstr ""
"Contas arquivadas não aparecem nem contam para o seu patrimônio líquido"
#: apps/accounts/models.py:59 apps/rules/models.py:19
#: apps/transactions/forms.py:59 apps/transactions/forms.py:380
#: apps/transactions/forms.py:622 apps/transactions/models.py:84
#: apps/transactions/models.py:188 apps/transactions/models.py:390
#: apps/transactions/forms.py:59 apps/transactions/forms.py:386
#: apps/transactions/forms.py:633 apps/transactions/models.py:84
#: apps/transactions/models.py:190 apps/transactions/models.py:392
msgid "Account"
msgstr "Conta"
#: apps/accounts/models.py:60 apps/transactions/filters.py:52
#: apps/accounts/models.py:60 apps/transactions/filters.py:53
#: templates/accounts/fragments/list.html:5
#: templates/accounts/pages/index.html:4 templates/includes/navbar.html:104
#: templates/includes/navbar.html:106
#: templates/accounts/pages/index.html:4
#: templates/import_app/fragments/list.html:5
#: templates/includes/navbar.html:104 templates/includes/navbar.html:106
#: templates/transactions/fragments/summary.html:9
msgid "Accounts"
msgstr "Contas"
@@ -221,8 +228,8 @@ msgstr "Dados da entidade inválidos. Forneça um ID ou nome."
msgid "Either 'date' or 'reference_date' must be provided."
msgstr "É necessário fornecer “date” ou “reference_date”."
#: apps/common/fields/forms/dynamic_select.py:128
#: apps/common/fields/forms/dynamic_select.py:164
#: apps/common/fields/forms/dynamic_select.py:127
#: apps/common/fields/forms/dynamic_select.py:163
msgid "Error creating new instance"
msgstr "Erro criando nova instância"
@@ -231,7 +238,7 @@ msgstr "Erro criando nova instância"
msgid "Ungrouped"
msgstr "Não agrupado"
#: apps/common/fields/month_year.py:21 apps/common/fields/month_year.py:45
#: apps/common/fields/month_year.py:23 apps/common/fields/month_year.py:51
msgid "Invalid date format. Use YYYY-MM."
msgstr "Formato de data inválido. Use AAAA-MM."
@@ -314,6 +321,14 @@ msgstr "Erro"
msgid "Info"
msgstr "Informação"
#: apps/common/widgets/datepicker.py:55 apps/common/widgets/datepicker.py:197
msgid "Today"
msgstr "Hoje"
#: apps/common/widgets/datepicker.py:139
msgid "Now"
msgstr "Agora"
#: apps/common/widgets/tom_select.py:10
msgid "Remove"
msgstr "Remover"
@@ -328,14 +343,22 @@ msgstr "Limpar"
msgid "No results..."
msgstr "Sem resultados..."
#: apps/currencies/forms.py:15 apps/currencies/models.py:15
#: apps/currencies/forms.py:16 apps/currencies/models.py:15
msgid "Prefix"
msgstr "Prefixo"
#: apps/currencies/forms.py:16 apps/currencies/models.py:16
#: apps/currencies/forms.py:17 apps/currencies/models.py:16
msgid "Suffix"
msgstr "Sufixo"
#: apps/currencies/forms.py:68 apps/dca/models.py:156 apps/rules/models.py:22
#: apps/transactions/forms.py:63 apps/transactions/forms.py:242
#: apps/transactions/models.py:94
#: templates/dca/fragments/strategy/details.html:53
#: templates/exchange_rates/fragments/table.html:11
msgid "Date"
msgstr "Data"
#: apps/currencies/models.py:8
msgid "Currency Code"
msgstr "Código da Moeda"
@@ -348,7 +371,7 @@ msgstr "Nome da Moeda"
msgid "Decimal Places"
msgstr "Casas Decimais"
#: apps/currencies/models.py:33 apps/transactions/filters.py:59
#: apps/currencies/models.py:33 apps/transactions/filters.py:60
#: templates/currencies/fragments/list.html:5
#: templates/currencies/pages/index.html:4 templates/includes/navbar.html:112
#: templates/includes/navbar.html:114
@@ -419,8 +442,8 @@ msgid "Payment Currency"
msgstr "Moeda de pagamento"
#: apps/dca/models.py:27 apps/dca/models.py:179 apps/rules/models.py:26
#: apps/transactions/forms.py:250 apps/transactions/models.py:105
#: apps/transactions/models.py:237 apps/transactions/models.py:418
#: apps/transactions/forms.py:256 apps/transactions/models.py:107
#: apps/transactions/models.py:239 apps/transactions/models.py:420
msgid "Notes"
msgstr "Notas"
@@ -436,18 +459,11 @@ msgstr "Estratégias CMP"
msgid "Strategy"
msgstr "Estratégia"
#: apps/dca/models.py:156 apps/rules/models.py:22
#: apps/transactions/forms.py:238 apps/transactions/models.py:94
#: templates/dca/fragments/strategy/details.html:52
#: templates/exchange_rates/fragments/table.html:10
msgid "Date"
msgstr "Data"
#: apps/dca/models.py:158 templates/dca/fragments/strategy/details.html:54
#: apps/dca/models.py:158 templates/dca/fragments/strategy/details.html:55
msgid "Amount Paid"
msgstr "Quantia paga"
#: apps/dca/models.py:161 templates/dca/fragments/strategy/details.html:53
#: apps/dca/models.py:161 templates/dca/fragments/strategy/details.html:54
msgid "Amount Received"
msgstr "Quantia recebida"
@@ -516,8 +532,8 @@ msgid "A value for this field already exists in the rule."
msgstr "Já existe um valor para esse campo na regra."
#: apps/rules/models.py:10 apps/rules/models.py:25
#: apps/transactions/forms.py:242 apps/transactions/models.py:104
#: apps/transactions/models.py:195 apps/transactions/models.py:404
#: apps/transactions/forms.py:248 apps/transactions/models.py:105
#: apps/transactions/models.py:197 apps/transactions/models.py:406
msgid "Description"
msgstr "Descrição"
@@ -526,32 +542,32 @@ msgid "Trigger"
msgstr "Gatilho"
#: apps/rules/models.py:20 apps/transactions/models.py:91
#: apps/transactions/models.py:193 apps/transactions/models.py:396
#: apps/transactions/models.py:195 apps/transactions/models.py:398
msgid "Type"
msgstr "Tipo"
#: apps/rules/models.py:21 apps/transactions/filters.py:22
#: apps/rules/models.py:21 apps/transactions/filters.py:23
#: apps/transactions/models.py:93
msgid "Paid"
msgstr "Pago"
#: apps/rules/models.py:23 apps/transactions/forms.py:62
#: apps/transactions/forms.py:241 apps/transactions/forms.py:407
#: apps/transactions/forms.py:649 apps/transactions/models.py:95
#: apps/transactions/models.py:211 apps/transactions/models.py:420
#: apps/rules/models.py:23 apps/transactions/forms.py:66
#: apps/transactions/forms.py:245 apps/transactions/forms.py:415
#: apps/transactions/models.py:95 apps/transactions/models.py:213
#: apps/transactions/models.py:422
msgid "Reference Date"
msgstr "Data de Referência"
#: apps/rules/models.py:24 apps/transactions/models.py:100
#: apps/transactions/models.py:401
#: apps/transactions/models.py:403
msgid "Amount"
msgstr "Quantia"
#: apps/rules/models.py:29 apps/transactions/filters.py:80
#: apps/transactions/forms.py:55 apps/transactions/forms.py:403
#: apps/transactions/forms.py:645 apps/transactions/models.py:69
#: apps/transactions/models.py:120 apps/transactions/models.py:233
#: apps/transactions/models.py:415 templates/entities/fragments/list.html:5
#: apps/rules/models.py:29 apps/transactions/filters.py:81
#: apps/transactions/forms.py:55 apps/transactions/forms.py:409
#: apps/transactions/forms.py:656 apps/transactions/models.py:69
#: apps/transactions/models.py:122 apps/transactions/models.py:235
#: apps/transactions/models.py:417 templates/entities/fragments/list.html:5
#: templates/entities/pages/index.html:4 templates/includes/navbar.html:100
msgid "Entities"
msgstr "Entidades"
@@ -596,19 +612,19 @@ msgstr "Ação atualizada com sucesso"
msgid "Action deleted successfully"
msgstr "Ação apagada com sucesso"
#: apps/transactions/filters.py:23 templates/includes/navbar.html:45
#: apps/transactions/filters.py:24 templates/includes/navbar.html:45
msgid "Projected"
msgstr "Previsto"
#: apps/transactions/filters.py:40
#: apps/transactions/filters.py:41
msgid "Content"
msgstr "Conteúdo"
#: apps/transactions/filters.py:46
#: apps/transactions/filters.py:47
msgid "Transaction Type"
msgstr "Tipo de Transação"
#: apps/transactions/filters.py:66 templates/categories/fragments/list.html:5
#: apps/transactions/filters.py:67 templates/categories/fragments/list.html:5
#: templates/categories/pages/index.html:4 templates/includes/navbar.html:96
msgid "Categories"
msgstr "Categorias"
@@ -617,39 +633,39 @@ msgstr "Categorias"
msgid "Date from"
msgstr "Data de"
#: apps/transactions/filters.py:97 apps/transactions/filters.py:107
#: apps/transactions/filters.py:96 apps/transactions/filters.py:106
msgid "Until"
msgstr "Até"
#: apps/transactions/filters.py:102
#: apps/transactions/filters.py:101
msgid "Reference date from"
msgstr "Data de Referência de"
#: apps/transactions/filters.py:112
#: apps/transactions/filters.py:111
msgid "Amount min"
msgstr "Quantia miníma"
#: apps/transactions/filters.py:117
#: apps/transactions/filters.py:116
msgid "Amount max"
msgstr "Quantia máxima"
#: apps/transactions/forms.py:184
#: apps/transactions/forms.py:189
msgid "From Account"
msgstr "Conta de origem"
#: apps/transactions/forms.py:189
#: apps/transactions/forms.py:194
msgid "To Account"
msgstr "Conta de destino"
#: apps/transactions/forms.py:196
#: apps/transactions/forms.py:201
msgid "From Amount"
msgstr "Quantia de origem"
#: apps/transactions/forms.py:201
#: apps/transactions/forms.py:206
msgid "To Amount"
msgstr "Quantia de destino"
#: apps/transactions/forms.py:315
#: apps/transactions/forms.py:321
#: templates/calendar_view/pages/calendar.html:84
#: templates/monthly_overview/pages/overview.html:84
#: templates/yearly_overview/pages/overview_by_account.html:79
@@ -657,27 +673,27 @@ msgstr "Quantia de destino"
msgid "Transfer"
msgstr "Transferir"
#: apps/transactions/forms.py:330
#: apps/transactions/forms.py:336
msgid "From and To accounts must be different."
msgstr "As contas De e Para devem ser diferentes."
#: apps/transactions/forms.py:524
#: apps/transactions/forms.py:535
msgid "Tag name"
msgstr "Nome da Tag"
#: apps/transactions/forms.py:556
#: apps/transactions/forms.py:567
msgid "Entity name"
msgstr "Nome da entidade"
#: apps/transactions/forms.py:588
#: apps/transactions/forms.py:599
msgid "Category name"
msgstr "Nome da Categoria"
#: apps/transactions/forms.py:590
#: apps/transactions/forms.py:601
msgid "Muted categories won't count towards your monthly total"
msgstr "As categorias silenciadas não serão contabilizadas em seu total mensal"
#: apps/transactions/forms.py:760
#: apps/transactions/forms.py:773
msgid "End date should be after the start date"
msgstr "Data final deve ser após data inicial"
@@ -749,19 +765,19 @@ msgstr "Renda"
msgid "Expense"
msgstr "Despesa"
#: apps/transactions/models.py:131 apps/transactions/models.py:240
#: apps/transactions/models.py:133 apps/transactions/models.py:242
msgid "Installment Plan"
msgstr "Parcelamento"
#: apps/transactions/models.py:140 apps/transactions/models.py:441
#: apps/transactions/models.py:142 apps/transactions/models.py:443
msgid "Recurring Transaction"
msgstr "Transação Recorrente"
#: apps/transactions/models.py:144
#: apps/transactions/models.py:146
msgid "Transaction"
msgstr "Transação"
#: apps/transactions/models.py:145 templates/includes/navbar.html:53
#: apps/transactions/models.py:147 templates/includes/navbar.html:53
#: templates/includes/navbar.html:94
#: templates/recurring_transactions/fragments/list_transactions.html:5
#: templates/recurring_transactions/fragments/table.html:37
@@ -769,95 +785,95 @@ msgstr "Transação"
msgid "Transactions"
msgstr "Transações"
#: apps/transactions/models.py:182
#: apps/transactions/models.py:184
msgid "Yearly"
msgstr "Anual"
#: apps/transactions/models.py:183 apps/users/models.py:26
#: apps/transactions/models.py:185 apps/users/models.py:26
#: templates/includes/navbar.html:25
msgid "Monthly"
msgstr "Mensal"
#: apps/transactions/models.py:184
#: apps/transactions/models.py:186
msgid "Weekly"
msgstr "Semanal"
#: apps/transactions/models.py:185
#: apps/transactions/models.py:187
msgid "Daily"
msgstr "Diária"
#: apps/transactions/models.py:198
#: apps/transactions/models.py:200
msgid "Number of Installments"
msgstr "Número de Parcelas"
#: apps/transactions/models.py:203
#: apps/transactions/models.py:205
msgid "Installment Start"
msgstr "Parcela inicial"
#: apps/transactions/models.py:204
#: apps/transactions/models.py:206
msgid "The installment number to start counting from"
msgstr "O número da parcela a partir do qual se inicia a contagem"
#: apps/transactions/models.py:209 apps/transactions/models.py:424
#: apps/transactions/models.py:211 apps/transactions/models.py:426
msgid "Start Date"
msgstr "Data de Início"
#: apps/transactions/models.py:213 apps/transactions/models.py:425
#: apps/transactions/models.py:215 apps/transactions/models.py:427
msgid "End Date"
msgstr "Data Final"
#: apps/transactions/models.py:218
#: apps/transactions/models.py:220
msgid "Recurrence"
msgstr "Recorrência"
#: apps/transactions/models.py:221
#: apps/transactions/models.py:223
msgid "Installment Amount"
msgstr "Valor da Parcela"
#: apps/transactions/models.py:241 templates/includes/navbar.html:62
#: apps/transactions/models.py:243 templates/includes/navbar.html:62
#: templates/installment_plans/fragments/list.html:5
#: templates/installment_plans/pages/index.html:4
msgid "Installment Plans"
msgstr "Parcelamentos"
#: apps/transactions/models.py:383
#: apps/transactions/models.py:385
msgid "day(s)"
msgstr "dia(s)"
#: apps/transactions/models.py:384
#: apps/transactions/models.py:386
msgid "week(s)"
msgstr "semana(s)"
#: apps/transactions/models.py:385
#: apps/transactions/models.py:387
msgid "month(s)"
msgstr "mês(es)"
#: apps/transactions/models.py:386
#: apps/transactions/models.py:388
msgid "year(s)"
msgstr "ano(s)"
#: apps/transactions/models.py:388
#: apps/transactions/models.py:390
#: templates/recurring_transactions/fragments/list.html:24
msgid "Paused"
msgstr "Pausado"
#: apps/transactions/models.py:427
#: apps/transactions/models.py:429
msgid "Recurrence Type"
msgstr "Tipo de recorrência"
#: apps/transactions/models.py:430
#: apps/transactions/models.py:432
msgid "Recurrence Interval"
msgstr "Intervalo de recorrência"
#: apps/transactions/models.py:434
#: apps/transactions/models.py:436
msgid "Last Generated Date"
msgstr "Última data gerada"
#: apps/transactions/models.py:437
#: apps/transactions/models.py:439
msgid "Last Generated Reference Date"
msgstr "Última data de referência gerada"
#: apps/transactions/models.py:442 templates/includes/navbar.html:64
#: apps/transactions/models.py:444 templates/includes/navbar.html:64
#: templates/recurring_transactions/fragments/list.html:5
#: templates/recurring_transactions/pages/index.html:4
msgid "Recurring Transactions"
@@ -901,15 +917,15 @@ msgstr "Entidade apagada com sucesso"
msgid "Installment Plan added successfully"
msgstr "Parcelamento adicionado com sucesso"
#: apps/transactions/views/installment_plans.py:116
#: apps/transactions/views/installment_plans.py:118
msgid "Installment Plan updated successfully"
msgstr "Parcelamento atualizado com sucesso"
#: apps/transactions/views/installment_plans.py:141
#: apps/transactions/views/installment_plans.py:143
msgid "Installment Plan refreshed successfully"
msgstr "Parcelamento atualizado com sucesso"
#: apps/transactions/views/installment_plans.py:160
#: apps/transactions/views/installment_plans.py:162
msgid "Installment Plan deleted successfully"
msgstr "Parcelamento apagado com sucesso"
@@ -917,23 +933,23 @@ msgstr "Parcelamento apagado com sucesso"
msgid "Recurring Transaction added successfully"
msgstr "Transação Recorrente adicionada com sucesso"
#: apps/transactions/views/recurring_transactions.py:144
#: apps/transactions/views/recurring_transactions.py:146
msgid "Recurring Transaction updated successfully"
msgstr "Transação Recorrente atualizada com sucesso"
#: apps/transactions/views/recurring_transactions.py:188
#: apps/transactions/views/recurring_transactions.py:192
msgid "Recurring transaction unpaused successfully"
msgstr "Transação Recorrente despausada com sucesso"
#: apps/transactions/views/recurring_transactions.py:191
#: apps/transactions/views/recurring_transactions.py:195
msgid "Recurring transaction paused successfully"
msgstr "Transação Recorrente pausada com sucesso"
#: apps/transactions/views/recurring_transactions.py:217
#: apps/transactions/views/recurring_transactions.py:221
msgid "Recurring transaction finished successfully"
msgstr "Transação Recorrente finalizada com sucesso"
#: apps/transactions/views/recurring_transactions.py:238
#: apps/transactions/views/recurring_transactions.py:242
msgid "Recurring Transaction deleted successfully"
msgstr "Transação Recorrente apagada com sucesso"
@@ -949,19 +965,23 @@ msgstr "Tag atualizada com sucesso"
msgid "Tag deleted successfully"
msgstr "Tag apagada com sucesso"
#: apps/transactions/views/transactions.py:45
#: apps/transactions/views/transactions.py:47
msgid "Transaction added successfully"
msgstr "Transação adicionada com sucesso"
#: apps/transactions/views/transactions.py:76
#: apps/transactions/views/transactions.py:79
msgid "Transaction updated successfully"
msgstr "Transação atualizada com sucesso"
#: apps/transactions/views/transactions.py:101
#: apps/transactions/views/transactions.py:110
msgid "Transaction duplicated successfully"
msgstr "Transação duplicada com sucesso"
#: apps/transactions/views/transactions.py:153
msgid "Transaction deleted successfully"
msgstr "Transação apagada com sucesso"
#: apps/transactions/views/transactions.py:127
#: apps/transactions/views/transactions.py:179
msgid "Transfer added successfully"
msgstr "Transferência adicionada com sucesso"
@@ -1001,7 +1021,21 @@ msgstr "E-mail ou senha inválidos"
msgid "This account is deactivated"
msgstr "Essa conta está desativada"
#: apps/users/forms.py:65
#: apps/users/forms.py:50 apps/users/forms.py:63
#: templates/monthly_overview/pages/overview.html:116
#: templates/transactions/pages/transactions.html:36
msgid "Default"
msgstr "Padrão"
#: apps/users/forms.py:85 apps/users/models.py:40
msgid "Date Format"
msgstr "Formato de Data"
#: apps/users/forms.py:90 apps/users/models.py:45
msgid "Datetime Format"
msgstr "Formato de Data e Hora"
#: apps/users/forms.py:117
msgid "Save"
msgstr "Salvar"
@@ -1025,19 +1059,19 @@ msgstr "Todas as transações"
msgid "Calendar"
msgstr "Calendário"
#: apps/users/models.py:41 apps/users/models.py:47
#: apps/users/models.py:50 apps/users/models.py:56
msgid "Auto"
msgstr "Automático"
#: apps/users/models.py:43
#: apps/users/models.py:52
msgid "Language"
msgstr "Linguagem"
#: apps/users/models.py:49
#: apps/users/models.py:58
msgid "Time Zone"
msgstr "Fuso horário"
#: apps/users/models.py:55
#: apps/users/models.py:64
msgid "Start page"
msgstr "Página inicial"
@@ -1073,9 +1107,10 @@ msgstr "Editar grupo de conta"
#: templates/accounts/fragments/list.html:37
#: templates/categories/fragments/table.html:24
#: templates/currencies/fragments/list.html:33
#: templates/dca/fragments/strategy/details.html:63
#: templates/dca/fragments/strategy/details.html:64
#: templates/entities/fragments/table.html:23
#: templates/exchange_rates/fragments/table.html:19
#: templates/exchange_rates/fragments/table.html:20
#: templates/import_app/fragments/list.html:37
#: templates/installment_plans/fragments/table.html:23
#: templates/recurring_transactions/fragments/table.html:25
#: templates/rules/fragments/list.html:33
@@ -1086,12 +1121,13 @@ msgstr "Ações"
#: templates/account_groups/fragments/list.html:36
#: templates/accounts/fragments/list.html:41
#: templates/categories/fragments/table.html:29
#: templates/cotton/transaction/item.html:109
#: templates/cotton/transaction/item.html:110
#: templates/currencies/fragments/list.html:37
#: templates/dca/fragments/strategy/details.html:67
#: templates/dca/fragments/strategy/details.html:68
#: templates/dca/fragments/strategy/list.html:34
#: templates/entities/fragments/table.html:28
#: templates/exchange_rates/fragments/table.html:23
#: templates/exchange_rates/fragments/table.html:24
#: templates/import_app/fragments/list.html:41
#: templates/installment_plans/fragments/table.html:27
#: templates/recurring_transactions/fragments/table.html:29
#: templates/rules/fragments/transaction_rule/view.html:22
@@ -1103,13 +1139,14 @@ msgstr "Editar"
#: templates/account_groups/fragments/list.html:43
#: templates/accounts/fragments/list.html:48
#: templates/categories/fragments/table.html:36
#: templates/cotton/transaction/item.html:116
#: templates/cotton/transaction/item.html:125
#: templates/cotton/ui/transactions_action_bar.html:50
#: templates/currencies/fragments/list.html:44
#: templates/dca/fragments/strategy/details.html:75
#: templates/dca/fragments/strategy/details.html:76
#: templates/dca/fragments/strategy/list.html:42
#: templates/entities/fragments/table.html:36
#: templates/exchange_rates/fragments/table.html:31
#: templates/exchange_rates/fragments/table.html:32
#: templates/import_app/fragments/list.html:48
#: templates/installment_plans/fragments/table.html:56
#: templates/mini_tools/unit_price_calculator.html:18
#: templates/recurring_transactions/fragments/table.html:91
@@ -1122,13 +1159,14 @@ msgstr "Apagar"
#: templates/account_groups/fragments/list.html:47
#: templates/accounts/fragments/list.html:52
#: templates/categories/fragments/table.html:41
#: templates/cotton/transaction/item.html:120
#: templates/cotton/transaction/item.html:129
#: templates/cotton/ui/transactions_action_bar.html:52
#: templates/currencies/fragments/list.html:48
#: templates/dca/fragments/strategy/details.html:80
#: templates/dca/fragments/strategy/details.html:81
#: templates/dca/fragments/strategy/list.html:46
#: templates/entities/fragments/table.html:40
#: templates/exchange_rates/fragments/table.html:36
#: templates/exchange_rates/fragments/table.html:37
#: templates/import_app/fragments/list.html:52
#: templates/installment_plans/fragments/table.html:48
#: templates/installment_plans/fragments/table.html:60
#: templates/recurring_transactions/fragments/table.html:53
@@ -1144,13 +1182,14 @@ msgstr "Tem certeza?"
#: templates/account_groups/fragments/list.html:48
#: templates/accounts/fragments/list.html:53
#: templates/categories/fragments/table.html:42
#: templates/cotton/transaction/item.html:121
#: templates/cotton/transaction/item.html:130
#: templates/cotton/ui/transactions_action_bar.html:53
#: templates/currencies/fragments/list.html:49
#: templates/dca/fragments/strategy/details.html:81
#: templates/dca/fragments/strategy/details.html:82
#: templates/dca/fragments/strategy/list.html:47
#: templates/entities/fragments/table.html:41
#: templates/exchange_rates/fragments/table.html:37
#: templates/exchange_rates/fragments/table.html:38
#: templates/import_app/fragments/list.html:53
#: templates/rules/fragments/list.html:49
#: templates/rules/fragments/transaction_rule/view.html:61
#: templates/tags/fragments/table.html:41
@@ -1160,12 +1199,13 @@ msgstr "Você não será capaz de reverter isso!"
#: templates/account_groups/fragments/list.html:49
#: templates/accounts/fragments/list.html:54
#: templates/categories/fragments/table.html:43
#: templates/cotton/transaction/item.html:122
#: templates/cotton/transaction/item.html:131
#: templates/currencies/fragments/list.html:50
#: templates/dca/fragments/strategy/details.html:82
#: templates/dca/fragments/strategy/details.html:83
#: templates/dca/fragments/strategy/list.html:48
#: templates/entities/fragments/table.html:42
#: templates/exchange_rates/fragments/table.html:38
#: templates/exchange_rates/fragments/table.html:39
#: templates/import_app/fragments/list.html:54
#: templates/installment_plans/fragments/table.html:62
#: templates/recurring_transactions/fragments/table.html:98
#: templates/rules/fragments/list.html:50
@@ -1179,18 +1219,22 @@ msgid "No account groups"
msgstr "Nenhum grupo de conta"
#: templates/accounts/fragments/account_reconciliation.html:6
#: templates/import_app/fragments/account_reconciliation.html:6
msgid "Account Reconciliation"
msgstr "Reconciliação do saldo"
#: templates/accounts/fragments/account_reconciliation.html:26
#: templates/import_app/fragments/account_reconciliation.html:26
msgid "Current balance"
msgstr "Saldo atual"
#: templates/accounts/fragments/account_reconciliation.html:39
#: templates/import_app/fragments/account_reconciliation.html:39
msgid "Difference"
msgstr "Diferença"
#: templates/accounts/fragments/account_reconciliation.html:70
#: templates/import_app/fragments/account_reconciliation.html:70
msgid "Reconcile balances"
msgstr "Reconciliar saldos"
@@ -1203,10 +1247,12 @@ msgid "Edit account"
msgstr "Editar conta"
#: templates/accounts/fragments/list.html:29
#: templates/import_app/fragments/list.html:29
msgid "Is Asset"
msgstr "É ativo"
#: templates/accounts/fragments/list.html:70
#: templates/import_app/fragments/list.html:70
msgid "No accounts"
msgstr "Nenhuma conta"
@@ -1238,11 +1284,11 @@ msgstr "SÁB"
msgid "SUN"
msgstr "DOM"
#: templates/calendar_view/fragments/list_transactions.html:5
#: templates/calendar_view/fragments/list_transactions.html:6
msgid "Transactions on"
msgstr "Transações em"
#: templates/calendar_view/fragments/list_transactions.html:15
#: templates/calendar_view/fragments/list_transactions.html:16
msgid "No transactions on this date"
msgstr "Nenhuma transação nesta data"
@@ -1300,10 +1346,14 @@ msgstr "Fechar"
msgid "Search"
msgstr "Buscar"
#: templates/cotton/transaction/item.html:5
#: templates/cotton/transaction/item.html:6
msgid "Select"
msgstr "Selecionar"
#: templates/cotton/transaction/item.html:117
msgid "Duplicate"
msgstr "Duplicar"
#: templates/cotton/ui/percentage_distribution.html:3
#: templates/cotton/ui/percentage_distribution.html:7
msgid "Projected Income"
@@ -1410,91 +1460,91 @@ msgstr "Editar entrada CMP"
msgid "Add DCA strategy"
msgstr "Adicionar estratégia CMP"
#: templates/dca/fragments/strategy/details.html:22
#: templates/dca/fragments/strategy/details.html:23
msgid "No exchange rate available"
msgstr "Nenhuma taxa de câmbio disponível"
#: templates/dca/fragments/strategy/details.html:33
#: templates/dca/fragments/strategy/details.html:34
msgid "Entries"
msgstr "Entradas"
#: templates/dca/fragments/strategy/details.html:55
#: templates/dca/fragments/strategy/details.html:56
msgid "Current Value"
msgstr "Valor atual"
#: templates/dca/fragments/strategy/details.html:56
#: templates/dca/fragments/strategy/details.html:57
msgid "P/L"
msgstr "P/L"
#: templates/dca/fragments/strategy/details.html:124
#: templates/dca/fragments/strategy/details.html:125
msgid "No entries for this DCA"
msgstr "Nenhuma entrada neste CMP"
#: templates/dca/fragments/strategy/details.html:125
#: templates/dca/fragments/strategy/details.html:126
#: templates/monthly_overview/fragments/list.html:41
#: templates/transactions/fragments/list_all.html:40
msgid "Try adding one"
msgstr "Tente adicionar uma"
#: templates/dca/fragments/strategy/details.html:135
#: templates/dca/fragments/strategy/details.html:136
msgid "Total Invested"
msgstr "Total investido"
#: templates/dca/fragments/strategy/details.html:149
#: templates/dca/fragments/strategy/details.html:150
msgid "Total Received"
msgstr "Total recebido"
#: templates/dca/fragments/strategy/details.html:163
#: templates/dca/fragments/strategy/details.html:164
msgid "Current Total Value"
msgstr "Valor total atual"
#: templates/dca/fragments/strategy/details.html:177
#: templates/dca/fragments/strategy/details.html:178
msgid "Average Entry Price"
msgstr "Preço médio de entrada"
#: templates/dca/fragments/strategy/details.html:191
#: templates/dca/fragments/strategy/details.html:192
msgid "Total P/L"
msgstr "P/L total"
#: templates/dca/fragments/strategy/details.html:207
#: templates/dca/fragments/strategy/details.html:208
#, python-format
msgid "Total %% P/L"
msgstr "P/L%% Total"
#: templates/dca/fragments/strategy/details.html:226
#: templates/dca/fragments/strategy/details.html:227
#, python-format
msgid "P/L %%"
msgstr "P/L %%"
#: templates/dca/fragments/strategy/details.html:288
#: templates/dca/fragments/strategy/details.html:289
msgid "Performance Over Time"
msgstr "Desempenho ao longo do tempo"
#: templates/dca/fragments/strategy/details.html:306
#: templates/dca/fragments/strategy/details.html:307
msgid "Entry Price"
msgstr "Preço de Entrada"
#: templates/dca/fragments/strategy/details.html:314
#: templates/dca/fragments/strategy/details.html:315
msgid "Current Price"
msgstr "Preço atual"
#: templates/dca/fragments/strategy/details.html:322
#: templates/dca/fragments/strategy/details.html:323
msgid "Amount Bought"
msgstr "Quantia comprada"
#: templates/dca/fragments/strategy/details.html:390
#: templates/dca/fragments/strategy/details.html:391
msgid "Entry Price vs Current Price"
msgstr "Preço de Entrada vs Preço Atual"
#: templates/dca/fragments/strategy/details.html:406
#: templates/dca/fragments/strategy/details.html:407
msgid "Days Between Investments"
msgstr "Dias entre investimentos"
#: templates/dca/fragments/strategy/details.html:453
#: templates/dca/fragments/strategy/details.html:454
msgid "Investment Frequency"
msgstr "Frequência de Investimento"
#: templates/dca/fragments/strategy/details.html:455
#: templates/dca/fragments/strategy/details.html:456
msgid "The straighter the blue line, the more consistent your DCA strategy is."
msgstr ""
"Quanto mais reta for a linha azul, mais consistente é sua estratégia de CMP."
@@ -1540,19 +1590,19 @@ msgstr "Editar taxa de câmbio"
msgid "All"
msgstr "Todas"
#: templates/exchange_rates/fragments/table.html:11
#: templates/exchange_rates/fragments/table.html:12
msgid "Pairing"
msgstr "Pares"
#: templates/exchange_rates/fragments/table.html:12
#: templates/exchange_rates/fragments/table.html:13
msgid "Rate"
msgstr "Taxa de Câmbio"
#: templates/exchange_rates/fragments/table.html:51
#: templates/exchange_rates/fragments/table.html:52
msgid "No exchange rates"
msgstr "Nenhuma taxa de câmbio"
#: templates/exchange_rates/fragments/table.html:58
#: templates/exchange_rates/fragments/table.html:59
#: templates/transactions/fragments/list_all.html:47
msgid "Page navigation"
msgstr "Navegação por página"
@@ -1585,7 +1635,7 @@ msgstr "Calculadora de preço unitário"
#: templates/includes/navbar.html:82
#: templates/mini_tools/currency_converter/currency_converter.html:8
#: templates/mini_tools/currency_converter/currency_converter.html:13
#: templates/mini_tools/currency_converter/currency_converter.html:15
msgid "Currency Converter"
msgstr "Conversor de Moeda"
@@ -1681,7 +1731,7 @@ msgstr "Isso excluirá o parcelamento e todas as transações associadas a ele"
msgid "No installment plans"
msgstr "Nenhum parcelamento"
#: templates/mini_tools/currency_converter/currency_converter.html:56
#: templates/mini_tools/currency_converter/currency_converter.html:58
msgid "Invert"
msgstr "Inverter"
@@ -1759,22 +1809,17 @@ msgid "Filter transactions"
msgstr "Filtrar transações"
#: templates/monthly_overview/pages/overview.html:114
#: templates/transactions/pages/transactions.html:33
#: templates/transactions/pages/transactions.html:34
msgid "Order by"
msgstr "Ordernar por"
#: templates/monthly_overview/pages/overview.html:116
#: templates/transactions/pages/transactions.html:35
msgid "Default"
msgstr "Padrão"
#: templates/monthly_overview/pages/overview.html:117
#: templates/transactions/pages/transactions.html:36
#: templates/transactions/pages/transactions.html:37
msgid "Oldest first"
msgstr "Mais antigas primeiro"
#: templates/monthly_overview/pages/overview.html:118
#: templates/transactions/pages/transactions.html:37
#: templates/transactions/pages/transactions.html:38
msgid "Newest first"
msgstr "Mais novas primeiro"

View File

@@ -1,41 +0,0 @@
{
"name": "WYGIWYH",
"icons": [
{
"src": "\/static\/img\/favicon\/android-icon-36x36.png",
"sizes": "36x36",
"type": "image\/png",
"density": "0.75"
},
{
"src": "\/static\/img\/favicon\/android-icon-48x48.png",
"sizes": "48x48",
"type": "image\/png",
"density": "1.0"
},
{
"src": "\/static\/img\/favicon\/android-icon-72x72.png",
"sizes": "72x72",
"type": "image\/png",
"density": "1.5"
},
{
"src": "\/static\/img\/favicon\/android-icon-96x96.png",
"sizes": "96x96",
"type": "image\/png",
"density": "2.0"
},
{
"src": "\/static\/img\/favicon\/android-icon-144x144.png",
"sizes": "144x144",
"type": "image\/png",
"density": "3.0"
},
{
"src": "\/static\/img\/favicon\/android-icon-192x192.png",
"sizes": "192x192",
"type": "image\/png",
"density": "4.0"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -0,0 +1,19 @@
{% extends 'extends/offcanvas.html' %}
{% load json %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Add new import profile' %}{% endblock %}
{% block body %}
{% if message %}
<div class="alert alert-info" role="alert" id="msg" hx-preserve="true">
<h6 class="alert-heading tw-italic tw-font-bold">{% trans 'A message from the author' %}</h6>
<hr>
<p class="mb-0">{{ message|linebreaksbr }}</p>
</div>
{% endif %}
<form hx-post="{% url 'import_profiles_add' %}" hx-target="#generic-offcanvas" novalidate hx-vals='{"message": {% if message %}{{ message|json }}{% else %}""{% endif %}}'>
{% crispy form %}
</form>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Edit import profile' %}{% endblock %}
{% block body %}
<form hx-post="{% url 'import_profile_edit' profile_id=profile.id %}" hx-target="#generic-offcanvas" novalidate>
{% crispy form %}
</form>
{% endblock %}

View File

@@ -0,0 +1,90 @@
{% load i18n %}
<div class="container px-md-3 py-3 column-gap-5">
<div class="tw-text-3xl fw-bold font-monospace tw-w-full mb-3">
{% spaceless %}
<div>{% translate 'Import Profiles' %}<span>
<span class="dropdown" data-bs-toggle="tooltip"
data-bs-title="{% translate "Add" %}">
<a class="text-decoration-none tw-text-2xl p-1" role="button"
data-bs-toggle="dropdown"
data-bs-title="{% translate "Add" %}" aria-expanded="false">
<i class="fa-solid fa-circle-plus fa-fw"></i>
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item"
role="button"
hx-get="{% url 'import_profiles_add' %}"
hx-target="#generic-offcanvas">{% trans 'New' %}</a></li>
<li><a class="dropdown-item"
role="button"
hx-get="{% url 'import_presets_list' %}"
hx-target="#persistent-generic-offcanvas-left">{% trans 'From preset' %}</a></li>
</ul>
</span>
</span></div>
{% endspaceless %}
</div>
<div class="card">
<div class="card-body table-responsive">
{% if profiles %}
<c-config.search></c-config.search>
<table class="table table-hover text-nowrap">
<thead>
<tr>
<th scope="col" class="col-auto"></th>
<th scope="col" class="col">{% translate 'Name' %}</th>
<th scope="col" class="col">{% translate 'Version' %}</th>
</tr>
</thead>
<tbody>
{% for profile in profiles %}
<tr class="profile">
<td class="col-auto">
<div class="btn-group" role="group" aria-label="{% translate 'Actions' %}">
<a class="btn btn-secondary btn-sm"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'import_profile_edit' profile_id=profile.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-secondary btn-sm text-success"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Runs" %}"
hx-get="{% url 'import_profile_runs_list' profile_id=profile.id %}"
hx-target="#persistent-generic-offcanvas-left">
<i class="fa-solid fa-person-running fa-fw"></i></a>
<a class="btn btn-secondary btn-sm text-primary"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Import" %}"
hx-get="{% url 'import_run_add' profile_id=profile.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-file-import fa-fw"></i></a>
<a class="btn btn-secondary btn-sm text-danger"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'import_profile_delete' profile_id=profile.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
</div>
</td>
<td class="col">{{ profile.name }}</td>
<td class="col">{{ profile.get_version_display }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<c-msg.empty title="{% translate "No import profiles" %}" remove-padding></c-msg.empty>
{% endif %}
</div>
</div>
</div>

View File

@@ -0,0 +1,43 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Import Presets' %}{% endblock %}
{% block body %}
{% if presets %}
<div id="search" class="mb-3">
<label class="w-100">
<input type="search"
class="form-control"
placeholder="{% translate 'Search' %}"
_="on input or search
show < .col /> in <#items/>
when its textContent.toLowerCase() contains my value.toLowerCase()"/>
</label>
</div>
<div class="row row-cols-1 g-4" id="items">
{% for preset in presets %}
<a class="text-decoration-none"
role="button"
hx-get="{% url 'import_profiles_add' %}"
hx-vals='{"yaml_config": {{ preset.config }}, "name": "{{ preset.name }}", "version": "{{ preset.schema_version }}", "message": {{ preset.message }}}'
hx-target="#generic-offcanvas">
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">{{ preset.name }}</h5>
<hr>
<p>{{ preset.description }}</p>
<p>{% trans 'By' %} {{ preset.authors|join:", " }}</p>
</div>
</div>
</div>
</a>
{% endfor %}
{% else %}
<c-msg.empty title="{% translate "No presets yet" %}"></c-msg.empty>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Import file with profile' %} {{ profile.name }}{% endblock %}
{% block body %}
<form hx-post="{% url 'import_run_add' profile_id=profile.id %}" hx-target="#generic-offcanvas" enctype="multipart/form-data" novalidate>
{% crispy form %}
</form>
{% endblock %}

View File

@@ -0,0 +1,120 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Runs for ' %}{{ profile.name }}{% endblock %}
{% block body %}
<div hx-get="{% url "import_profile_runs_list" profile_id=profile.id %}"
hx-trigger="updated from:window"
hx-target="closest .offcanvas"
class="show-loading"
hx-swap="show:none scroll:none">
{% if runs %}
<div class="row row-cols-1 g-4">
{% for run in runs %}
<div class="col">
<div class="card">
<div class="card-header tw-text-sm {% if run.status == run.Status.QUEUED %}tw-text-white{% elif run.status == run.Status.PROCESSING %}text-warning{% elif run.status == run.Status.FINISHED %}text-success{% else %}text-danger{% endif %}">
<span><i class="fa-solid {% if run.status == run.Status.QUEUED %}fa-hourglass-half{% elif run.status == run.Status.PROCESSING %}fa-spinner{% elif run.status == run.Status.FINISHED %}fa-check{% else %}fa-xmark{% endif %} fa-fw me-2"></i>{{ run.get_status_display }}</span>
</div>
<div class="card-body">
<h5 class="card-title"><i class="fa-solid fa-hashtag me-1 tw-text-xs tw-text-gray-400"></i>{{ run.id }}<span class="tw-text-xs tw-text-gray-400 ms-1">({{ run.file_name }})</span></h5>
<hr>
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 w-100 g-4">
<div class="col">
<div class="d-flex flex-row">
<div class="d-flex flex-column">
<div class="text-body-secondary tw-text-xs tw-font-medium">
{% trans 'Total Items' %}
</div>
<div class="tw-text-sm">
{{ run.total_rows }}
</div>
</div>
</div>
</div>
<div class="col">
<div class="d-flex flex-row">
<div class="d-flex flex-column">
<div class="text-body-secondary tw-text-xs tw-font-medium">
{% trans 'Processed Items' %}
</div>
<div class="tw-text-sm">
{{ run.processed_rows }}
</div>
</div>
</div>
</div>
<div class="col">
<div class="d-flex flex-row">
<div class="d-flex flex-column">
<div class="text-body-secondary tw-text-xs tw-font-medium">
{% trans 'Skipped Items' %}
</div>
<div class="tw-text-sm">
{{ run.skipped_rows }}
</div>
</div>
</div>
</div>
<div class="col">
<div class="d-flex flex-row">
<div class="d-flex flex-column">
<div class="text-body-secondary tw-text-xs tw-font-medium">
{% trans 'Failed Items' %}
</div>
<div class="tw-text-sm">
{{ run.failed_rows }}
</div>
</div>
</div>
</div>
<div class="col">
<div class="d-flex flex-row">
<div class="d-flex flex-column">
<div class="text-body-secondary tw-text-xs tw-font-medium">
{% trans 'Successful Items' %}
</div>
<div class="tw-text-sm">
{{ run.successful_rows }}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card-footer text-body-secondary">
<a class="text-decoration-none text-info"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Logs" %}"
hx-get="{% url 'import_run_log' profile_id=profile.id run_id=run.id %}"
hx-target="#generic-offcanvas"><i class="fa-solid fa-file-lines"></i></a>
<a class="text-decoration-none text-danger"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'import_run_delete' profile_id=profile.id run_id=run.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this! All imported items will be kept." %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i>
</a>
</div>
</div>
</div>
{% endfor %}
{% else %}
<c-msg.empty title="{% translate "No runs yet" %}"></c-msg.empty>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,13 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Logs for' %} #{{ run.id }}{% endblock %}
{% block body %}
<div class="card tw-max-h-full tw-overflow-auto">
<div class="card-body">
{{ run.logs|linebreaks }}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,8 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% translate 'Import Profiles' %}{% endblock %}
{% block content %}
<div hx-get="{% url 'import_profiles_list' %}" hx-trigger="load, updated from:window" class="show-loading"></div>
{% endblock %}

View File

@@ -0,0 +1,8 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% translate 'Import Runs' %}{% endblock %}
{% block content %}
<div hx-get="{% url 'impor' %}" hx-trigger="load, updated from:window" class="show-loading"></div>
{% endblock %}

View File

@@ -12,7 +12,6 @@
<link rel="icon" type="image/png" sizes="32x32" href="{% static 'img/favicon/favicon-32x32.png' %}">
<link rel="icon" type="image/png" sizes="96x96" href="{% static 'img/favicon/favicon-96x96.png' %}">
<link rel="icon" type="image/png" sizes="16x16" href="{% static 'img/favicon/favicon-16x16.png' %}">
<link rel="manifest" href="{% static 'img/favicon/manifest.json' %}">
<meta name="msapplication-TileColor" content="#ffffff">
<meta name="msapplication-TileImage" content="{% static 'img/favicon/ms-icon-144x144.png' %}">
<meta name="theme-color" content="#ffffff">
<meta name="theme-color" content="#ffffff">

View File

@@ -120,6 +120,8 @@
<li><h6 class="dropdown-header">{% trans 'Automation' %}</h6></li>
<li><a class="dropdown-item {% active_link views='rules_index' %}"
href="{% url 'rules_index' %}">{% translate 'Rules' %}</a></li>
<li><a class="dropdown-item {% active_link views='import_profiles_index' %}"
href="{% url 'import_profiles_index' %}">{% translate 'Import' %} <span class="badge text-bg-primary">beta</span></a></li>
<li>
<hr class="dropdown-divider">
</li>

View File

@@ -1,3 +1,4 @@
{% load pwa %}
{% load formats %}
{% load i18n %}
{% load title %}
@@ -15,6 +16,7 @@
</title>
{% include 'includes/head/favicons.html' %}
{% progressive_web_app_meta %}
{% include 'includes/styles.html' %}
{% block extra_styles %}{% endblock %}

View File

@@ -1,3 +1,4 @@
{% load pwa %}
{% load title %}
{% load webpack_loader %}
<!doctype html>
@@ -11,8 +12,9 @@
{% endblock title %}
{% endfilter %}
</title>
{% include 'includes/head/favicons.html' %}
{% progressive_web_app_meta %}
{% include 'includes/styles.html' %}
{% block extra_styles %}{% endblock %}

View File

@@ -1,6 +1,6 @@
volumes:
wygiwyh_dev_postgres_data: {}
temp:
wygiwyh_temp:
services:
web: &django
@@ -13,6 +13,7 @@ services:
volumes:
- ./app/:/usr/src/app/:z
- ./frontend/:/usr/src/frontend:z
- wygiwyh_temp:/usr/src/app/temp/
ports:
- "${OUTBOUND_PORT}:8000"
env_file:

View File

@@ -9,6 +9,8 @@ services:
- .env
depends_on:
- db
volumes:
- wygiwyh_temp:/usr/src/app/temp/
restart: unless-stopped
db:
@@ -29,5 +31,10 @@ services:
- db
env_file:
- .env
volumes:
- wygiwyh_temp:/usr/src/app/temp/
command: /start-procrastinate
restart: unless-stopped
volumes:
wygiwyh_temp:

View File

@@ -22,6 +22,15 @@ function isMobile() {
}
window.DatePicker = function createDynamicDatePicker(element) {
let todayButton = {
content: element.dataset.nowButtonTxt,
onClick: (dp) => {
let date = new Date();
dp.selectDate(date, {updateTime: true});
dp.setViewDate(date);
}
}
let isOnMobile = isMobile();
let baseOpts = {
@@ -30,7 +39,7 @@ window.DatePicker = function createDynamicDatePicker(element) {
timeFormat: element.dataset.timeFormat,
timepicker: element.dataset.timepicker === 'true',
autoClose: element.dataset.autoClose === 'true',
buttons: element.dataset.clearButton === 'true' ? ['clear', 'today'] : ['today'],
buttons: element.dataset.clearButton === 'true' ? ['clear', todayButton] : [todayButton],
locale: locales[element.dataset.language],
onSelect: ({date, formattedDate, datepicker}) => {
const _event = new CustomEvent("change", {
@@ -87,6 +96,15 @@ window.DatePicker = function createDynamicDatePicker(element) {
window.MonthYearPicker = function createDynamicDatePicker(element) {
let todayButton = {
content: element.dataset.nowButtonTxt,
onClick: (dp) => {
let date = new Date();
dp.selectDate(date, {updateTime: true});
dp.setViewDate(date);
}
}
let isOnMobile = isMobile();
let baseOpts = {
@@ -95,7 +113,7 @@ window.MonthYearPicker = function createDynamicDatePicker(element) {
minView: 'months',
dateFormat: 'MMMM yyyy',
autoClose: element.dataset.autoClose === 'true',
buttons: element.dataset.clearButton === 'true' ? ['clear', 'today'] : ['today'],
buttons: element.dataset.clearButton === 'true' ? ['clear', todayButton] : [todayButton],
locale: locales[element.dataset.language],
onSelect: ({date, formattedDate, datepicker}) => {
const _event = new CustomEvent("change", {

View File

@@ -9,7 +9,7 @@ django-filter==24.3
django-debug-toolbar==4.3.0
django-cachalot~=2.6.3
django-cotton~=1.2.1
django-pwa~=2.0.1
djangorestframework~=3.15.2
drf-spectacular~=0.27.2
@@ -24,3 +24,5 @@ requests~=2.32.3
pytz~=2024.2
python-dateutil~=2.9.0.post0
simpleeval~=1.0.0
pydantic~=2.10.5
PyYAML~=6.0.2