mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-02-25 08:54:52 +01:00
Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2d32fd7e9 | ||
|
|
53175aacb9 | ||
|
|
1dc03b0a84 | ||
|
|
ba2d654f15 | ||
|
|
93d04572df | ||
|
|
38379ab2b1 | ||
|
|
928ad33111 | ||
|
|
d0172b5524 | ||
|
|
e4a2b83c83 | ||
|
|
1c28dd5513 | ||
|
|
1c713fac19 | ||
|
|
096f24e0a2 | ||
|
|
f1cd658972 | ||
|
|
a85221468a | ||
|
|
e3d3a7cf91 | ||
|
|
4ef4609a96 | ||
|
|
962a8efa26 | ||
|
|
d7de6c17a9 | ||
|
|
a805880e9b | ||
|
|
aaee602b71 | ||
|
|
7635b66638 | ||
|
|
bcc96588bf | ||
|
|
cabd03e7e6 | ||
|
|
16fbead2f9 | ||
|
|
ece44f2726 | ||
|
|
a415e285ee | ||
|
|
00b8727664 | ||
|
|
6f096fd3ff | ||
|
|
07fcbe1f45 | ||
|
|
0f14fd0c62 | ||
|
|
61d5aba67c | ||
|
|
76df16e489 | ||
|
|
34e6914d41 | ||
|
|
f2cc070505 | ||
|
|
18d8e8ed1a | ||
|
|
2ff33526ae | ||
|
|
8a127a9f4f | ||
|
|
a52f682c4f | ||
|
|
3440d4405e | ||
|
|
87345cf235 | ||
|
|
50efc51f87 | ||
|
|
493bf268bb | ||
|
|
8992cd98b5 | ||
|
|
f7c3a2f320 | ||
|
|
d96787cfeb | ||
|
|
32b5864736 | ||
|
|
02adfd828a | ||
|
|
c14b666921 | ||
|
|
5d2b9ae0b3 | ||
|
|
d5dfe5bba0 | ||
|
|
72ceec7452 | ||
|
|
eae0e00d1f | ||
|
|
cc0125241f | ||
|
|
e3bab503a0 | ||
|
|
c089c49b7d | ||
|
|
0fccdbe573 | ||
|
|
b9810ce062 | ||
|
|
4cc32e3f57 | ||
|
|
8db13b082b | ||
|
|
e73e1dfc25 | ||
|
|
ae91c51967 | ||
|
|
3ef6b0ac5c | ||
|
|
ba0c54767c | ||
|
|
2d8864773c | ||
|
|
f96d8d2862 | ||
|
|
3ccb0e19eb | ||
|
|
238f205513 | ||
|
|
a94e0b4904 | ||
|
|
86dac632c4 | ||
|
|
fbb26b8442 | ||
|
|
c171e0419a |
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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")),
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
18
app/apps/accounts/migrations/0008_alter_account_name.py
Normal file
18
app/apps/accounts/migrations/0008_alter_account_name.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
11
app/apps/common/templatetags/json.py
Normal file
11
app/apps/common/templatetags/json.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
0
app/apps/import_app/__init__.py
Normal file
0
app/apps/import_app/__init__.py
Normal file
6
app/apps/import_app/admin.py
Normal file
6
app/apps/import_app/admin.py
Normal 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)
|
||||
6
app/apps/import_app/apps.py
Normal file
6
app/apps/import_app/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ImportConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.import_app"
|
||||
64
app/apps/import_app/forms.py
Normal file
64
app/apps/import_app/forms.py
Normal 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"
|
||||
),
|
||||
),
|
||||
)
|
||||
51
app/apps/import_app/migrations/0001_initial.py
Normal file
51
app/apps/import_app/migrations/0001_initial.py
Normal 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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
0
app/apps/import_app/migrations/__init__.py
Normal file
0
app/apps/import_app/migrations/__init__.py
Normal file
83
app/apps/import_app/models.py
Normal file
83
app/apps/import_app/models.py
Normal 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)
|
||||
1
app/apps/import_app/schemas/__init__.py
Normal file
1
app/apps/import_app/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
import apps.import_app.schemas.v1 as version_1
|
||||
400
app/apps/import_app/schemas/v1.py
Normal file
400
app/apps/import_app/schemas/v1.py
Normal 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
|
||||
3
app/apps/import_app/services/__init__.py
Normal file
3
app/apps/import_app/services/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from apps.import_app.services.v1 import ImportService as ImportServiceV1
|
||||
|
||||
from apps.import_app.services.presets import PresetService
|
||||
45
app/apps/import_app/services/presets.py
Normal file
45
app/apps/import_app/services/presets.py
Normal 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
|
||||
633
app/apps/import_app/services/v1.py
Normal file
633
app/apps/import_app/services/v1.py
Normal 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()
|
||||
21
app/apps/import_app/tasks.py
Normal file
21
app/apps/import_app/tasks.py
Normal 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")
|
||||
3
app/apps/import_app/tests.py
Normal file
3
app/apps/import_app/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
56
app/apps/import_app/urls.py
Normal file
56
app/apps/import_app/urls.py
Normal 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",
|
||||
),
|
||||
]
|
||||
232
app/apps/import_app/views.py
Normal file
232
app/apps/import_app/views.py
Normal 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",
|
||||
},
|
||||
)
|
||||
@@ -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):
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'},
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
0
app/import_presets/.gitkeep
Normal file
0
app/import_presets/.gitkeep
Normal file
54
app/import_presets/nuconta/config.yml
Normal file
54
app/import_presets/nuconta/config.yml
Normal 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
|
||||
7
app/import_presets/nuconta/manifest.json
Normal file
7
app/import_presets/nuconta/manifest.json
Normal 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"
|
||||
}
|
||||
2062
app/locale/nl/LC_MESSAGES/django.po
Normal file
2062
app/locale/nl/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
app/static/img/pwa/splash-640x1136.png
Normal file
BIN
app/static/img/pwa/splash-640x1136.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
app/static/img/pwa/splash-750x1334.png
Normal file
BIN
app/static/img/pwa/splash-750x1334.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
19
app/templates/import_app/fragments/profiles/add.html
Normal file
19
app/templates/import_app/fragments/profiles/add.html
Normal 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 %}
|
||||
11
app/templates/import_app/fragments/profiles/edit.html
Normal file
11
app/templates/import_app/fragments/profiles/edit.html
Normal 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 %}
|
||||
90
app/templates/import_app/fragments/profiles/list.html
Normal file
90
app/templates/import_app/fragments/profiles/list.html
Normal 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>
|
||||
@@ -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 %}
|
||||
11
app/templates/import_app/fragments/runs/add.html
Normal file
11
app/templates/import_app/fragments/runs/add.html
Normal 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 %}
|
||||
120
app/templates/import_app/fragments/runs/list.html
Normal file
120
app/templates/import_app/fragments/runs/list.html
Normal 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 %}
|
||||
13
app/templates/import_app/fragments/runs/log.html
Normal file
13
app/templates/import_app/fragments/runs/log.html
Normal 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 %}
|
||||
8
app/templates/import_app/pages/profiles_index.html
Normal file
8
app/templates/import_app/pages/profiles_index.html
Normal 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 %}
|
||||
8
app/templates/import_app/pages/runs_index.html
Normal file
8
app/templates/import_app/pages/runs_index.html
Normal 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 %}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user