Compare commits

...

20 Commits

Author SHA1 Message Date
Herculino Trotta 0ee32724f1 style(toas): move toast to the top of offcanvas 2026-06-06 04:33:23 -03:00
Herculino Trotta 6a19381672 feat(transactions): add attachments 2026-06-06 04:33:06 -03:00
Herculino Trotta 248fec8b4c Merge pull request #541 from eitchtee/weblate
Translations update from Weblate
2026-05-02 16:30:56 -03:00
Weblate b34c0557fa Merge remote-tracking branch 'origin/main' 2026-05-02 19:30:37 +00:00
Herculino Trotta 2af4066aab Merge pull request #542 from eitchtee/fix-procrastinate
fix: stabilize Procrastinate worker database handling
2026-05-02 16:30:33 -03:00
Herculino Trotta d72ff3cdf5 fix(rules): allow category expressions to clear categories 2026-05-02 16:16:27 -03:00
Herculino Trotta 63c69e5c6a test(api): expect unauthorized for anonymous requests 2026-05-02 16:16:08 -03:00
Herculino Trotta 78171183cc test(currencies): avoid test discovery collision 2026-05-02 16:15:48 -03:00
Herculino Trotta 34a2b6bfd4 fix(procrastinate): close Django connections around jobs 2026-05-02 16:15:26 -03:00
masttera 1dc24f855e locale(Russian): update translation
Currently translated at 75.2% (545 of 724 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/ru/
2026-05-01 07:24:32 +00:00
masttera 1390aff07d locale(Russian): update translation
Currently translated at 74.4% (539 of 724 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/ru/
2026-05-01 06:24:32 +00:00
Herculino Trotta 8fc11b0acf feat(transactions): hide filter on page load to prevent flashing 2026-05-01 00:07:43 -03:00
Herculino Trotta 9a30a0d3c0 chore: bump versions and other minor things 2026-05-01 00:07:15 -03:00
Herculino Trotta 10eecd09ff fix(frontend): hyperscript not working correctly for offcanvas and modals 2026-04-30 23:16:19 -03:00
Herculino Trotta 2cfb3fb12e fix(frontend): bootstrap-grid-plugin broke 2026-04-30 23:03:42 -03:00
Herculino Trotta 47af8b135b Merge pull request #540 from eitchtee/weblate
Translations update from Weblate
2026-04-30 18:09:27 -03:00
Herculino Trotta 39d0e63375 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (724 of 724 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2026-04-30 02:24:32 +00:00
Herculino Trotta 792154eba2 Merge pull request #539 from eitchtee/dev
fix(tom-select): dropdown covers select field when height increases
2026-04-23 23:30:56 -03:00
Herculino Trotta e627dd50be Merge pull request #537 from eitchtee/dev
fix: deduplication breaks when given m2m fields
2026-04-18 11:48:44 -03:00
Herculino Trotta be24ca014e Merge pull request #536 from eitchtee/dev
chore: deps upgrade
2026-04-18 10:48:18 -03:00
36 changed files with 1007 additions and 77 deletions
+3
View File
@@ -165,3 +165,6 @@ cython_debug/
node_modules/
postgres_data/
.prod.env
# Private local uploads
app/attachments/
+29
View File
@@ -0,0 +1,29 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Docker: Dev",
"type": "node-terminal",
"request": "launch",
"command": "docker compose --env-file .env -f docker-compose.dev.yml up --build",
"cwd": "${workspaceFolder}",
"postDebugTask": "Docker: Dev Down"
},
{
"name": "Docker: Dev (no rebuild)",
"type": "node-terminal",
"request": "launch",
"command": "docker compose --env-file .env -f docker-compose.dev.yml up",
"cwd": "${workspaceFolder}",
"postDebugTask": "Docker: Dev Down"
},
{
"name": "Docker: Prod",
"type": "node-terminal",
"request": "launch",
"command": "docker compose --env-file .prod.env -f docker-compose.prod.yml up --build",
"cwd": "${workspaceFolder}",
"postDebugTask": "Docker: Prod Down"
}
]
}
+119
View File
@@ -0,0 +1,119 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Docker: Dev",
"type": "shell",
"command": "docker",
"args": [
"compose",
"--env-file",
".env",
"-f",
"docker-compose.dev.yml",
"up",
"--build"
],
"options": {
"cwd": "${workspaceFolder}"
},
"group": "build",
"problemMatcher": []
},
{
"label": "Docker: Dev (no rebuild)",
"type": "shell",
"command": "docker",
"args": [
"compose",
"--env-file",
".env",
"-f",
"docker-compose.dev.yml",
"up"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": []
},
{
"label": "Docker: Dev Refresh Vite Deps",
"type": "shell",
"command": "docker compose --env-file .env -f docker-compose.dev.yml rm -sfv vite; docker compose --env-file .env -f docker-compose.dev.yml up --build",
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": []
},
{
"label": "Docker: Dev Down",
"type": "shell",
"command": "docker",
"args": [
"compose",
"--env-file",
".env",
"-f",
"docker-compose.dev.yml",
"down"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": []
},
{
"label": "Docker: Prod",
"type": "shell",
"command": "docker",
"args": [
"compose",
"--env-file",
".prod.env",
"-f",
"docker-compose.prod.yml",
"up",
"--build"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": []
},
{
"label": "Docker: Prod Down",
"type": "shell",
"command": "docker",
"args": [
"compose",
"--env-file",
".prod.env",
"-f",
"docker-compose.prod.yml",
"down"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": []
},
{
"label": "Django: Runserver localhost:8000",
"type": "shell",
"command": "${command:python.interpreterPath}",
"args": [
"manage.py",
"runserver",
"localhost:8000"
],
"options": {
"cwd": "${workspaceFolder}/app",
"env": {
"PYTHONUNBUFFERED": "1"
}
},
"problemMatcher": []
}
]
}
+8 -7
View File
@@ -311,6 +311,7 @@ LOCALE_PATHS = [BASE_DIR / "locale"]
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "static_files"
ATTACHMENT_MEDIA_ROOT = BASE_DIR / "attachments"
STATICFILES_DIRS = [
ROOT_DIR / "frontend" / "build",
@@ -440,14 +441,14 @@ REST_FRAMEWORK = {
"apps.api.permissions.NotInDemoMode",
"rest_framework.permissions.DjangoModelPermissions",
],
'DEFAULT_FILTER_BACKENDS': [
'django_filters.rest_framework.DjangoFilterBackend',
'rest_framework.filters.OrderingFilter',
"DEFAULT_FILTER_BACKENDS": [
"django_filters.rest_framework.DjangoFilterBackend",
"rest_framework.filters.OrderingFilter",
],
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.BasicAuthentication',
'rest_framework.authentication.SessionAuthentication',
'rest_framework.authentication.TokenAuthentication',
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.BasicAuthentication",
"rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.TokenAuthentication",
],
"DEFAULT_PAGINATION_CLASS": "apps.api.custom.pagination.CustomPageNumberPagination",
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
+2 -2
View File
@@ -90,10 +90,10 @@ class AccountBalanceAPITests(TestCase):
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_get_balance_unauthenticated(self):
"""Test unauthenticated request returns 403"""
"""Test unauthenticated request returns 401"""
unauthenticated_client = APIClient()
response = unauthenticated_client.get(
f"/api/accounts/{self.account.id}/balance/"
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+6 -6
View File
@@ -159,7 +159,7 @@ column_mapping:
self.assertIn("import_run_id", response.data)
def test_unauthenticated_request(self):
"""Test unauthenticated request returns 403"""
"""Test unauthenticated request returns 401"""
unauthenticated_client = APIClient()
csv_content = b"date,description,amount\n2025-01-01,Test,100"
@@ -173,7 +173,7 @@ column_mapping:
format="multipart",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
@override_settings(
@@ -266,11 +266,11 @@ column_mapping:
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_profiles_unauthenticated(self):
"""Test unauthenticated request returns 403"""
"""Test unauthenticated request returns 401"""
unauthenticated_client = APIClient()
response = unauthenticated_client.get("/api/import/profiles/")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
@override_settings(
@@ -397,8 +397,8 @@ column_mapping:
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_runs_unauthenticated(self):
"""Test unauthenticated request returns 403"""
"""Test unauthenticated request returns 401"""
unauthenticated_client = APIClient()
response = unauthenticated_client.get("/api/import/runs/")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+42 -1
View File
@@ -1,6 +1,47 @@
import functools
import inspect
import procrastinate
from django.db import close_old_connections
_CONNECTION_CLEANUP_WRAPPED = "_wygiwyh_connection_cleanup_wrapped"
def _wrap_task_with_django_connection_cleanup(task):
if getattr(task.func, _CONNECTION_CLEANUP_WRAPPED, False):
return
func = task.func
if inspect.iscoroutinefunction(func):
@functools.wraps(func)
async def async_wrapped(*args, **kwargs):
close_old_connections()
try:
return await func(*args, **kwargs)
finally:
close_old_connections()
wrapped = async_wrapped
else:
@functools.wraps(func)
def sync_wrapped(*args, **kwargs):
close_old_connections()
try:
return func(*args, **kwargs)
finally:
close_old_connections()
wrapped = sync_wrapped
setattr(wrapped, _CONNECTION_CLEANUP_WRAPPED, True)
task.func = wrapped
def on_app_ready(app: procrastinate.App):
"""This function is ran upon procrastinate initialization."""
...
for task in set(app.tasks.values()):
_wrap_task_with_django_connection_cleanup(task)
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,89 @@
from unittest.mock import patch
import procrastinate
from django.db import connection
from django.test import SimpleTestCase, TransactionTestCase
from procrastinate.testing import InMemoryConnector
from apps.common.procrastinate import on_app_ready
def make_app_with_task(func):
app = procrastinate.App(connector=InMemoryConnector())
task = app.task(name="sample_task")(func)
return app, task
class ProcrastinateConnectionCleanupTests(SimpleTestCase):
def test_app_ready_closes_old_connections_around_sync_tasks(self):
calls = []
def sample_task(value):
calls.append(("task", value))
return value * 2
app, task = make_app_with_task(sample_task)
with patch(
"apps.common.procrastinate.close_old_connections",
create=True,
side_effect=lambda: calls.append(("cleanup", None)),
):
on_app_ready(app)
result = task.func(3)
self.assertEqual(result, 6)
self.assertEqual(
calls,
[
("cleanup", None),
("task", 3),
("cleanup", None),
],
)
def test_app_ready_closes_old_connections_when_sync_task_raises(self):
calls = []
def sample_task():
calls.append(("task", None))
raise RuntimeError("boom")
app, task = make_app_with_task(sample_task)
with patch(
"apps.common.procrastinate.close_old_connections",
create=True,
side_effect=lambda: calls.append(("cleanup", None)),
):
on_app_ready(app)
with self.assertRaises(RuntimeError):
task.func()
self.assertEqual(
calls,
[
("cleanup", None),
("task", None),
("cleanup", None),
],
)
class ProcrastinateConnectionRecoveryTests(TransactionTestCase):
def test_wrapped_task_recovers_from_closed_django_connection(self):
def sample_task():
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
return cursor.fetchone()[0]
app, task = make_app_with_task(sample_task)
on_app_ready(app)
connection.ensure_connection()
connection.connection.close()
self.assertEqual(task.func(), 1)
+6 -2
View File
@@ -365,7 +365,9 @@ def check_for_transaction_rules(
if processed_action.set_category:
value = simple.eval(processed_action.set_category)
if isinstance(value, int):
if value is None:
transaction.category = None
elif isinstance(value, int):
transaction.category = TransactionCategory.objects.get(id=value)
else:
transaction.category = TransactionCategory.objects.get(name=value)
@@ -458,7 +460,9 @@ def check_for_transaction_rules(
transaction.account = account
elif field == TransactionRuleAction.Field.category:
if isinstance(new_value, int):
if new_value is None:
transaction.category = None
elif isinstance(new_value, int):
category = TransactionCategory.objects.get(id=new_value)
transaction.category = category
elif isinstance(new_value, str):
+1
View File
@@ -0,0 +1 @@
+82
View File
@@ -0,0 +1,82 @@
from datetime import date
from decimal import Decimal
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.test import TransactionTestCase
from apps.accounts.models import Account
from apps.currencies.models import Currency
from apps.rules.models import TransactionRule, UpdateOrCreateTransactionRuleAction
from apps.rules.tasks import check_for_transaction_rules
from apps.transactions.models import Transaction
def run_check_for_transaction_rules_without_worker_wrapper(**kwargs):
task_func = check_for_transaction_rules.func
task_func = getattr(task_func, "__wrapped__", task_func)
return task_func(**kwargs)
class CheckForTransactionRulesTests(TransactionTestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
email="rules@example.com",
password="testpass123",
)
self.currency = Currency.objects.create(
code="USD",
name="US Dollar",
decimal_places=2,
)
self.account = Account.objects.create(
name="Main Account",
currency=self.currency,
owner=self.user,
)
@patch("apps.rules.signals.check_for_transaction_rules.defer")
def test_update_or_create_action_can_clear_category_from_none_expression(
self, mock_defer
):
source_transaction = Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
amount=Decimal("10.00"),
date=date(2026, 5, 4),
reference_date=date(2026, 5, 1),
description="Source without category",
category=None,
owner=self.user,
)
rule = TransactionRule.objects.create(
active=True,
on_create=False,
on_update=True,
name="Copy transaction",
trigger="True",
owner=self.user,
)
UpdateOrCreateTransactionRuleAction.objects.create(
rule=rule,
set_account="account_id",
set_type="'EX'",
set_date="date",
set_reference_date="reference_date",
set_amount="amount",
set_description="'Generated transaction'",
set_category="category_name",
)
run_check_for_transaction_rules_without_worker_wrapper(
instance_id=source_transaction.id,
user_id=self.user.id,
signal="transaction_updated",
)
generated_transaction = Transaction.objects.get(
description="Generated transaction"
)
self.assertIsNone(generated_transaction.category)
+52
View File
@@ -14,6 +14,7 @@ from apps.common.widgets.tom_select import TomSelect
from apps.rules.signals import transaction_created, transaction_updated
from apps.transactions.models import (
InstallmentPlan,
TransactionAttachment,
QuickTransaction,
RecurringTransaction,
Transaction,
@@ -36,6 +37,22 @@ from django.db.models import Q
from django.utils.translation import gettext_lazy as _
class MultipleFileInput(forms.ClearableFileInput):
allow_multiple_selected = True
class MultipleFileField(forms.FileField):
widget = MultipleFileInput
def clean(self, data, initial=None):
single_file_clean = super().clean
if isinstance(data, (list, tuple)):
return [single_file_clean(file, initial) for file in data]
if data:
return [single_file_clean(data, initial)]
return []
class TransactionForm(forms.ModelForm):
category = DynamicModelChoiceField(
create_field="name",
@@ -247,6 +264,41 @@ class TransactionForm(forms.ModelForm):
return instance
class TransactionAttachmentForm(forms.Form):
attachments = MultipleFileField(
required=True,
label=_("Attachments"),
help_text=_("Files are private and only visible to users with access to this transaction."),
)
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(
"attachments",
FormActions(
NoClassSubmit("submit", _("Upload"), css_class="btn btn-primary"),
),
)
def save(self, transaction, uploaded_by):
created = []
for attachment in self.cleaned_data.get("attachments") or []:
created.append(
TransactionAttachment.objects.create(
transaction=transaction,
file=attachment,
original_name=attachment.name,
content_type=getattr(attachment, "content_type", ""),
size=attachment.size,
uploaded_by=uploaded_by,
)
)
return created
class QuickTransactionForm(forms.ModelForm):
category = DynamicModelChoiceField(
create_field="name",
@@ -0,0 +1,38 @@
# Generated by Django 5.2.13 on 2026-06-06 02:34
import apps.transactions.models
import apps.transactions.storage
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('transactions', '0048_recurringtransaction_keep_at_most'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='TransactionAttachment',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('file', models.FileField(storage=apps.transactions.storage.PrivateMediaStorage(), upload_to=apps.transactions.models.transaction_attachment_path, verbose_name='File')),
('original_name', models.CharField(max_length=255, verbose_name='Original Name')),
('content_type', models.CharField(blank=True, max_length=255, verbose_name='Content Type')),
('size', models.PositiveBigIntegerField(default=0, verbose_name='Size')),
('created_at', models.DateTimeField(auto_now_add=True)),
('transaction', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='transactions.transaction', verbose_name='Transaction')),
('uploaded_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transaction_attachments', to=settings.AUTH_USER_MODEL, verbose_name='Uploaded By')),
],
options={
'verbose_name': 'Transaction Attachment',
'verbose_name_plural': 'Transaction Attachments',
'db_table': 'transaction_attachments',
'ordering': ['-created_at', 'original_name'],
},
),
]
+64 -1
View File
@@ -1,6 +1,8 @@
import decimal
import logging
import uuid
from copy import deepcopy
from pathlib import Path
from apps.common.fields.month_year import MonthYearModelField
from apps.common.functions.decimals import truncate_decimal
@@ -13,13 +15,15 @@ from apps.common.models import (
)
from apps.common.templatetags.decimal import drop_trailing_zeros, localize_number
from apps.currencies.utils.convert import convert
from apps.transactions.storage import PrivateMediaStorage
from apps.transactions.validators import validate_decimal_places, validate_non_negative
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.core.validators import MinValueValidator
from django.db import models, transaction
from django.db.models import Q
from django.dispatch import Signal
from django.db.models.signals import post_delete
from django.dispatch import Signal, receiver
from django.template.defaultfilters import date
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
@@ -32,6 +36,11 @@ transaction_updated = Signal()
transaction_deleted = Signal()
def transaction_attachment_path(instance, filename):
extension = Path(filename).suffix
return f"transaction_attachments/{instance.transaction_id}/{instance.id}{extension}"
class SoftDeleteQuerySet(models.QuerySet):
@staticmethod
def _emit_signals(instances, created=False, old_data=None):
@@ -526,6 +535,60 @@ class Transaction(OwnedObject):
return new_obj
class TransactionAttachment(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
transaction = models.ForeignKey(
Transaction,
on_delete=models.CASCADE,
related_name="attachments",
verbose_name=_("Transaction"),
)
file = models.FileField(
upload_to=transaction_attachment_path,
storage=PrivateMediaStorage(),
verbose_name=_("File"),
)
original_name = models.CharField(max_length=255, verbose_name=_("Original Name"))
content_type = models.CharField(
max_length=255, blank=True, verbose_name=_("Content Type")
)
size = models.PositiveBigIntegerField(default=0, verbose_name=_("Size"))
uploaded_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="transaction_attachments",
verbose_name=_("Uploaded By"),
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = _("Transaction Attachment")
verbose_name_plural = _("Transaction Attachments")
db_table = "transaction_attachments"
ordering = ["-created_at", "original_name"]
def save(self, *args, **kwargs):
if self.file:
if not self.original_name:
self.original_name = Path(self.file.name).name
if not self.size:
self.size = self.file.size
if not self.content_type:
self.content_type = getattr(self.file.file, "content_type", "")
super().save(*args, **kwargs)
def __str__(self):
return self.original_name
@receiver(post_delete, sender=TransactionAttachment)
def delete_transaction_attachment_file(sender, instance, **kwargs):
if not instance.file.name:
return
storage = instance.file.storage
if storage.exists(instance.file.name):
storage.delete(instance.file.name)
class InstallmentPlan(models.Model):
class Recurrence(models.TextChoices):
+9
View File
@@ -0,0 +1,9 @@
from django.conf import settings
from django.core.files.storage import FileSystemStorage
class PrivateMediaStorage(FileSystemStorage):
def __init__(self, *args, **kwargs):
kwargs.setdefault("location", settings.ATTACHMENT_MEDIA_ROOT)
kwargs.setdefault("base_url", None)
super().__init__(*args, **kwargs)
@@ -0,0 +1,219 @@
import shutil
import tempfile
from datetime import date
from decimal import Decimal
from pathlib import Path
from apps.accounts.models import Account
from apps.common.middleware.thread_local import delete_current_user, write_current_user
from apps.currencies.models import Currency
from apps.transactions.models import Transaction, TransactionAttachment
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.urls import reverse
@override_settings(
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
},
},
WHITENOISE_AUTOREFRESH=True,
)
class TransactionAttachmentTests(TestCase):
def setUp(self):
self.attachment_media_root = tempfile.mkdtemp()
self.override_private_media = override_settings(
ATTACHMENT_MEDIA_ROOT=self.attachment_media_root
)
self.override_private_media.enable()
self.addCleanup(self.override_private_media.disable)
self.addCleanup(shutil.rmtree, self.attachment_media_root, ignore_errors=True)
self.attachment_storage = TransactionAttachment._meta.get_field("file").storage
self.original_storage_location = self.attachment_storage._location
self.attachment_storage._location = self.attachment_media_root
self.attachment_storage.__dict__.pop("base_location", None)
self.attachment_storage.__dict__.pop("location", None)
self.addCleanup(self.restore_attachment_storage)
User = get_user_model()
self.user1 = User.objects.create_user(
email="user1@test.com", password="testpass123"
)
self.user2 = User.objects.create_user(
email="user2@test.com", password="testpass123"
)
self.currency = Currency.objects.create(
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
)
self.user1_account = Account.all_objects.create(
name="User1 Account", currency=self.currency, owner=self.user1
)
self.user2_account = Account.all_objects.create(
name="User2 Account", currency=self.currency, owner=self.user2
)
self.transaction = Transaction.userless_all_objects.create(
account=self.user1_account,
type=Transaction.Type.EXPENSE,
amount=Decimal("12.34"),
is_paid=True,
date=date(2026, 6, 5),
description="Receipt transaction",
owner=self.user1,
)
self.other_transaction = Transaction.userless_all_objects.create(
account=self.user2_account,
type=Transaction.Type.EXPENSE,
amount=Decimal("56.78"),
is_paid=True,
date=date(2026, 6, 5),
description="Other receipt transaction",
owner=self.user2,
)
def restore_attachment_storage(self):
self.attachment_storage._location = self.original_storage_location
self.attachment_storage.__dict__.pop("base_location", None)
self.attachment_storage.__dict__.pop("location", None)
def test_attachment_uses_uuid_and_preserves_original_download_name(self):
attachment = TransactionAttachment.objects.create(
transaction=self.transaction,
file=SimpleUploadedFile(
"receipt June.pdf", b"receipt bytes", content_type="application/pdf"
),
uploaded_by=self.user1,
)
self.assertEqual(attachment.original_name, "receipt June.pdf")
self.assertNotIn("receipt June.pdf", attachment.file.name)
self.client.force_login(self.user1)
response = self.client.get(
reverse(
"transaction_attachment_download",
kwargs={"attachment_id": attachment.id},
)
)
self.assertEqual(response.status_code, 200)
self.assertEqual(b"".join(response.streaming_content), b"receipt bytes")
self.assertIn('filename="receipt June.pdf"', response["Content-Disposition"])
def test_user_without_transaction_access_cannot_download_attachment(self):
attachment = TransactionAttachment.objects.create(
transaction=self.other_transaction,
file=SimpleUploadedFile("private.txt", b"private"),
uploaded_by=self.user2,
)
self.client.force_login(self.user1)
response = self.client.get(
reverse(
"transaction_attachment_download",
kwargs={"attachment_id": attachment.id},
)
)
self.assertEqual(response.status_code, 404)
def test_attachment_button_lives_in_transaction_hover_toolbar(self):
template = Path("templates/cotton/transaction/item.html").read_text()
before_toolbar, toolbar = template.split("{# Item actions#}", 1)
self.assertNotIn("transaction_attachments", before_toolbar)
self.assertLess(
toolbar.index("transaction_edit"),
toolbar.index("transaction_attachments"),
)
self.assertLess(
toolbar.index("transaction_attachments"),
toolbar.index("transaction_delete"),
)
def test_transaction_edit_form_does_not_include_attachment_upload(self):
self.client.force_login(self.user1)
response = self.client.get(
reverse("transaction_edit", kwargs={"transaction_id": self.transaction.id}),
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, "multipart/form-data")
self.assertNotContains(response, 'type="file"')
def test_attachment_management_uploads_multiple_attachments(self):
self.client.force_login(self.user1)
response = self.client.post(
reverse(
"transaction_attachments",
kwargs={"transaction_id": self.transaction.id},
),
{
"attachments": [
SimpleUploadedFile("first.txt", b"first"),
SimpleUploadedFile("second.txt", b"second"),
],
},
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "first.txt")
self.assertContains(response, "second.txt")
self.assertEqual(self.transaction.attachments.count(), 2)
def test_attachment_delete_returns_refreshed_attachment_list(self):
attachment = TransactionAttachment.objects.create(
transaction=self.transaction,
file=SimpleUploadedFile("delete-me.txt", b"delete"),
uploaded_by=self.user1,
)
self.client.force_login(self.user1)
response = self.client.delete(
reverse(
"transaction_attachment_delete",
kwargs={"attachment_id": attachment.id},
),
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, "delete-me.txt")
self.assertContains(response, "No attachments yet")
self.assertFalse(
TransactionAttachment.objects.filter(id=attachment.id).exists()
)
def test_hard_deleting_transaction_deletes_attachment_files(self):
attachment = TransactionAttachment.objects.create(
transaction=self.transaction,
file=SimpleUploadedFile("hard-delete.txt", b"delete with transaction"),
uploaded_by=self.user1,
)
file_path = Path(attachment.file.path)
self.assertTrue(file_path.exists())
write_current_user(self.user1)
self.addCleanup(delete_current_user)
self.transaction.delete()
self.assertTrue(file_path.exists())
self.assertTrue(TransactionAttachment.objects.filter(id=attachment.id).exists())
self.transaction.delete()
self.assertFalse(file_path.exists())
self.assertFalse(
TransactionAttachment.objects.filter(id=attachment.id).exists()
)
+20
View File
@@ -81,6 +81,26 @@ urlpatterns = [
views.transaction_move_to_today,
name="transaction_move_to_today",
),
path(
"transaction/<int:transaction_id>/attachments/",
views.transaction_attachments,
name="transaction_attachments",
),
path(
"transaction/<int:transaction_id>/attachments/list/",
views.transaction_attachments_list,
name="transaction_attachments_list",
),
path(
"transaction/attachments/<uuid:attachment_id>/download/",
views.transaction_attachment_download,
name="transaction_attachment_download",
),
path(
"transaction/attachments/<uuid:attachment_id>/delete/",
views.transaction_attachment_delete,
name="transaction_attachment_delete",
),
path(
"transaction/<int:transaction_id>/delete/",
views.transaction_delete,
+102 -14
View File
@@ -1,32 +1,120 @@
import datetime
from copy import deepcopy
from dateutil.relativedelta import relativedelta
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Q, When, Case, Value, IntegerField
from django.http import HttpResponse, JsonResponse
from django.shortcuts import render, get_object_or_404
from django.utils import timezone
from django.utils.translation import gettext_lazy as _, ngettext_lazy
from django.views.decorators.http import require_http_methods
from apps.common.decorators.demo import disabled_on_demo
from apps.common.decorators.htmx import only_htmx
from apps.rules.signals import transaction_created, transaction_updated
from apps.transactions.filters import TransactionsFilter
from apps.transactions.forms import (
BulkEditTransactionForm,
TransactionAttachmentForm,
TransactionForm,
TransferForm,
BulkEditTransactionForm,
)
from apps.transactions.models import Transaction
from apps.transactions.models import Transaction, TransactionAttachment
from apps.transactions.utils.calculations import (
calculate_currency_totals,
calculate_account_totals,
calculate_currency_totals,
calculate_percentage_distribution,
)
from apps.transactions.utils.default_ordering import default_order
from dateutil.relativedelta import relativedelta
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Case, IntegerField, Q, Value, When
from django.http import FileResponse, Http404, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, render
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext_lazy
from django.views.decorators.http import require_http_methods
def _get_accessible_transaction_or_404(transaction_id):
return get_object_or_404(Transaction.objects, id=transaction_id)
def _get_accessible_attachment_or_404(attachment_id):
attachment = get_object_or_404(
TransactionAttachment.objects.select_related("transaction"),
id=attachment_id,
)
if not Transaction.objects.filter(id=attachment.transaction_id).exists():
raise Http404()
return attachment
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def transaction_attachments(request, transaction_id):
transaction = _get_accessible_transaction_or_404(transaction_id)
if request.method == "POST":
form = TransactionAttachmentForm(request.POST, request.FILES)
if form.is_valid():
form.save(transaction=transaction, uploaded_by=request.user)
messages.success(request, _("Attachment uploaded successfully"))
form = TransactionAttachmentForm()
else:
form = TransactionAttachmentForm()
response = render(
request,
"transactions/fragments/attachments_manage.html",
{"form": form, "transaction": transaction},
)
response["HX-Trigger"] = "toasts, updated"
return response
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def transaction_attachments_list(request, transaction_id):
transaction = _get_accessible_transaction_or_404(transaction_id)
return render(
request,
"transactions/fragments/attachments.html",
{"transaction": transaction},
)
@login_required
@disabled_on_demo
@require_http_methods(["GET"])
def transaction_attachment_download(request, attachment_id):
attachment = _get_accessible_attachment_or_404(attachment_id)
return FileResponse(
attachment.file.open("rb"),
as_attachment=False,
filename=attachment.original_name,
content_type=attachment.content_type or "application/octet-stream",
)
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["DELETE"])
def transaction_attachment_delete(request, attachment_id):
attachment = _get_accessible_attachment_or_404(attachment_id)
transaction = attachment.transaction
attachment.file.delete(save=False)
attachment.delete()
messages.success(request, _("Attachment deleted successfully"))
response = render(
request,
"transactions/fragments/attachments.html",
{"transaction": transaction},
)
response["HX-Trigger"] = "toasts, updated"
return response
@only_htmx
+3 -3
View File
@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-02-16 02:24+0000\n"
"PO-Revision-Date: 2026-03-02 22:30+0000\n"
"PO-Revision-Date: 2026-04-30 02:24+0000\n"
"Last-Translator: Herculino Trotta <netotrotta@gmail.com>\n"
"Language-Team: Portuguese (Brazil) <https://translations.herculino.com/"
"projects/wygiwyh/app/pt_BR/>\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n > 1;\n"
"X-Generator: Weblate 5.16.1\n"
"X-Generator: Weblate 5.17\n"
#: apps/accounts/forms.py:24
msgid "Group name"
@@ -3059,7 +3059,7 @@ msgstr "Abr"
#: templates/insights/fragments/month_by_month.html:98
msgid "May"
msgstr "Mai"
msgstr "Maio"
#: templates/insights/fragments/month_by_month.html:99
msgid "Jun"
+20 -20
View File
@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-02-16 02:24+0000\n"
"PO-Revision-Date: 2026-03-31 13:24+0000\n"
"PO-Revision-Date: 2026-05-01 07:24+0000\n"
"Last-Translator: masttera <mail.masttera@gmail.com>\n"
"Language-Team: Russian <https://translations.herculino.com/projects/wygiwyh/"
"app/ru/>\n"
@@ -18,7 +18,7 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
"X-Generator: Weblate 5.16.2\n"
"X-Generator: Weblate 5.17\n"
#: apps/accounts/forms.py:24
msgid "Group name"
@@ -1513,7 +1513,7 @@ msgstr ""
#: templates/insights/fragments/category_overview/index.html:87
#: templates/monthly_overview/fragments/monthly_summary.html:41
msgid "Income"
msgstr ""
msgstr "Доход"
#: apps/transactions/models.py:289 apps/transactions/models.py:988
#: templates/calendar_view/fragments/list.html:46
@@ -2178,7 +2178,7 @@ msgstr "Вы уверены?"
#: templates/rules/fragments/transaction_rule/view.html:97
#: templates/tags/fragments/table.html:56
msgid "You won't be able to revert this!"
msgstr ""
msgstr "Вы не сможете отменить это!"
#: templates/account_groups/fragments/list.html:60
#: templates/accounts/fragments/list.html:77
@@ -2237,7 +2237,7 @@ msgstr "Сверка балансов"
#: templates/accounts/fragments/add.html:5
msgid "Add account"
msgstr ""
msgstr "Добавить счёт"
#: templates/accounts/fragments/edit.html:5
msgid "Edit account"
@@ -2688,7 +2688,7 @@ msgstr ""
#: templates/exchange_rates_services/fragments/list.html:17
msgid "Fetch all"
msgstr ""
msgstr "Отметить все"
#: templates/exchange_rates_services/fragments/list.html:29
msgid "Service"
@@ -2719,7 +2719,7 @@ msgstr ""
#: templates/exchange_rates_services/fragments/list.html:77
msgid "No services configured"
msgstr ""
msgstr "Службы не настроены"
#: templates/export_app/pages/index.html:4 templates/includes/sidebar.html:205
msgid "Export and Restore"
@@ -2751,7 +2751,7 @@ msgstr ""
#: templates/import_app/fragments/profiles/list.html:80
msgid "No import profiles"
msgstr ""
msgstr "Нет профилей для импорта"
#: templates/import_app/fragments/profiles/list_presets.html:5
msgid "Import Presets"
@@ -2877,11 +2877,11 @@ msgstr ""
#: templates/includes/sidebar.html:69 templates/insights/pages/index.html:5
msgid "Insights"
msgstr ""
msgstr "Аналитика"
#: templates/includes/sidebar.html:75
msgid "Net Worth"
msgstr ""
msgstr "Чистый капитал"
#: templates/includes/sidebar.html:91
msgid "Trash Can"
@@ -2899,7 +2899,7 @@ msgstr "Трекер средней стоимости доллара"
#: templates/mini_tools/unit_price_calculator.html:4
#: templates/mini_tools/unit_price_calculator.html:9
msgid "Unit Price Calculator"
msgstr ""
msgstr "Калькулятор цены за единицу"
#: templates/includes/sidebar.html:130
#: templates/mini_tools/currency_converter/currency_converter.html:7
@@ -2909,7 +2909,7 @@ msgstr "Конвертер валют"
#: templates/includes/sidebar.html:139
msgid "Management"
msgstr ""
msgstr "Управление"
#: templates/includes/sidebar.html:190
msgid "Automation"
@@ -3025,7 +3025,7 @@ msgstr "Всё отлично!"
#: templates/insights/fragments/late_transactions.html:16
msgid "No late transactions"
msgstr ""
msgstr "Нет просроченных транзакций"
#: templates/insights/fragments/latest_transactions.html:14
msgid "No recent transactions"
@@ -3123,31 +3123,31 @@ msgstr "Диапазон дат"
#: templates/insights/pages/index.html:79
msgid "Account Flow"
msgstr ""
msgstr "Движение по счету"
#: templates/insights/pages/index.html:84
msgid "Currency Flow"
msgstr ""
msgstr "Движение валюты"
#: templates/insights/pages/index.html:89
msgid "Category Explorer"
msgstr ""
msgstr "Обзор по категориям"
#: templates/insights/pages/index.html:94
msgid "Categories Overview"
msgstr ""
msgstr "Все категории"
#: templates/insights/pages/index.html:112
msgid "Late Transactions"
msgstr ""
msgstr "Просроченные транзакции"
#: templates/insights/pages/index.html:117
msgid "Latest Transactions"
msgstr ""
msgstr "Последние транзакции"
#: templates/insights/pages/index.html:122
msgid "Emergency Fund"
msgstr ""
msgstr "Резервный фонд"
#: templates/insights/pages/index.html:127
msgid "Year by Year"
@@ -147,6 +147,12 @@
hx-target="#generic-offcanvas" hx-swap="innerHTML"
data-tippy-content="{% translate "Edit" %}">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-soft btn-sm transaction-action gap-1"
role="button"
hx-get="{% url 'transaction_attachments' transaction_id=transaction.id %}"
hx-target="#generic-offcanvas" hx-swap="innerHTML"
data-tippy-content="{% translate "Attachments" %}">
<i class="fa-solid fa-paperclip fa-fw"></i><span>{{ transaction.attachments.count }}</span></a>
<a class="btn btn-error btn-soft btn-sm transaction-action"
role="button"
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
@@ -268,7 +268,7 @@
</div>
{# Filter transactions form #}
<div class="z-1" x-show="filterOpen" x-collapse>
<div class="z-1" x-show="filterOpen" x-collapse x-cloak>
<div class="card card-body bg-base-200 mt-2">
<div class="text-right">
<button class="btn btn-outline btn-error btn-sm w-fit"
@@ -0,0 +1,32 @@
{% load i18n %}
<div id="transaction-attachments-list" class="mt-4">
<h3 class="font-semibold mb-2">{% translate 'Attachments' %}</h3>
{% if transaction.attachments.exists %}
<ul class="flex flex-col gap-2">
{% for attachment in transaction.attachments.all %}
<li class="flex items-center justify-between gap-3 rounded-box border border-base-content/20 p-3">
<a class="link link-primary truncate"
target="_blank"
href="{% url 'transaction_attachment_download' attachment_id=attachment.id %}">
{{ attachment.original_name }}
</a>
<button class="btn btn-error btn-sm btn-soft"
type="button"
hx-delete="{% url 'transaction_attachment_delete' attachment_id=attachment.id %}"
hx-trigger="confirmed"
hx-target="#transaction-attachments-list"
hx-swap="outerHTML"
data-title="{% translate 'Delete this attachment?' %}"
data-text="{% translate 'This file will be removed from the transaction.' %}"
data-confirm-text="{% translate 'Yes, delete it!' %}"
_="install prompt_swal">
<i class="fa-solid fa-trash fa-fw"></i>
</button>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-base-content/60">{% translate 'No attachments yet' %}</p>
{% endif %}
</div>
@@ -0,0 +1,17 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Transaction attachments' %}{% endblock %}
{% block body %}
<form hx-post="{% url 'transaction_attachments' transaction_id=transaction.id %}"
hx-target="#generic-offcanvas"
hx-swap="innerHTML"
enctype="multipart/form-data"
novalidate>
{% crispy form %}
</form>
{% include 'transactions/fragments/attachments.html' %}
{% endblock %}
@@ -218,7 +218,7 @@
</div>
{# Filter transactions form #}
<div class="z-1" x-show="filterOpen" x-collapse>
<div class="z-1" x-show="filterOpen" x-collapse x-cloak>
<div class="card card-body bg-base-200 mt-2">
<div class="text-right">
<button class="btn btn-outline btn-error btn-sm w-fit"
+2
View File
@@ -1,6 +1,7 @@
volumes:
wygiwyh_dev_postgres_data: {}
wygiwyh_temp:
wygiwyh_attachments:
services:
@@ -14,6 +15,7 @@ services:
- ./app/:/usr/src/app/:z
- ./frontend/:/usr/src/frontend:z
- wygiwyh_temp:/usr/src/app/temp/
- wygiwyh_attachments:/usr/src/app/attachments/
ports:
- "${OUTBOUND_PORT:-8000}:${INTERNAL_PORT:-8000}"
env_file:
+2
View File
@@ -6,6 +6,8 @@ services:
- "${OUTBOUND_PORT:-8000}:${INTERNAL_PORT:-8000}"
env_file:
- .env
volumes:
- ./media:/usr/src/app/attachments/
depends_on:
- db
restart: unless-stopped
+2 -2
View File
@@ -2,9 +2,9 @@ FROM node:lts-alpine
WORKDIR /usr/src/frontend
COPY ./frontend/package.json .
COPY ./frontend/package.json ./frontend/package-lock.json ./
RUN npm install --verbose && npm cache clean --force
RUN npm ci --verbose && npm cache clean --force
ENV PATH ./node_modules/.bin/:$PATH
+14 -11
View File
@@ -23,7 +23,7 @@
"bootstrap": "^5.3.8",
"chart.js": "^4.5.1",
"chartjs-chart-sankey": "^0.14.0",
"daisyui": "^5.5.5",
"daisyui": "5.5.19",
"htmx.org": "^2.0.8",
"hyperscript.org": "^0.9.14",
"mathjs": "^15.2.0",
@@ -1667,12 +1667,15 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.25",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.25.tgz",
"integrity": "sha512-2NovHVesVF5TXefsGX1yzx1xgr7+m9JQenvz6FQY3qd+YXkKkYiv+vTCc7OriP9mcDZpTC5mAOYN4ocd29+erA==",
"version": "2.10.24",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.24.tgz",
"integrity": "sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA==",
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
"baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/bootstrap": {
@@ -1743,9 +1746,9 @@
"license": "MIT"
},
"node_modules/caniuse-lite": {
"version": "1.0.30001754",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz",
"integrity": "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==",
"version": "1.0.30001791",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz",
"integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==",
"funding": [
{
"type": "opencollective",
@@ -1816,9 +1819,9 @@
}
},
"node_modules/daisyui": {
"version": "5.5.5",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.5.tgz",
"integrity": "sha512-ekvI93ZkWIJoCOtDl0D2QMxnWvTejk9V5nWBqRv+7t0xjiBXqAK5U6o6JE2RPvlIC3EqwNyUoIZSdHX9MZK3nw==",
"version": "5.5.19",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.19.tgz",
"integrity": "sha512-pbFAkl1VCEh/MPCeclKL61I/MqRIFFhNU7yiXoDDRapXN4/qNCoMxeCCswyxEEhqL5eiTTfwHvucFtOE71C9sA==",
"license": "MIT",
"funding": {
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
+1 -1
View File
@@ -30,7 +30,7 @@
"bootstrap": "^5.3.8",
"chart.js": "^4.5.1",
"chartjs-chart-sankey": "^0.14.0",
"daisyui": "^5.5.5",
"daisyui": "5.5.19",
"htmx.org": "^2.0.8",
"hyperscript.org": "^0.9.14",
"mathjs": "^15.2.0",
+5 -3
View File
@@ -1,4 +1,4 @@
import 'hyperscript.org';
import _hyperscript from 'hyperscript.org';
import './_htmx.js';
import Alpine from "alpinejs";
import mask from '@alpinejs/mask';
@@ -6,8 +6,10 @@ import collapse from '@alpinejs/collapse'
import { create, all } from 'mathjs';
window.Alpine = Alpine;
const _hyperscript = window._hyperscript;
window._hyperscript = _hyperscript;
if (!window._hyperscript) {
window._hyperscript = _hyperscript;
_hyperscript.browserInit();
}
window.math = create(all, {
number: 'BigNumber',
});
@@ -0,0 +1,3 @@
import twBootstrapGrid from "tw-bootstrap-grid";
export default twBootstrapGrid;
+4
View File
@@ -49,6 +49,10 @@ div:where(.swal2-container) {
z-index: 1101 !important;
}
#toasts .toast-container {
z-index: 1100;
}
.logo {
/* Set the background-color to DaisyUI CSS variable */
background-color: var(--color-primary);
+1 -1
View File
@@ -4,7 +4,7 @@
themes: wygiwyh_dark --default, wygiwyh_light;
logs: true;
}
@plugin "tw-bootstrap-grid";
@plugin "../plugins/tw-bootstrap-grid-plugin.js";
@plugin "daisyui/theme" {
name: "wygiwyh_light";