From 7a545fa3c88d1def7b424d639bd9cca18e3df112 Mon Sep 17 00:00:00 2001 From: Gilles <43683714+corp-0@users.noreply.github.com> Date: Sat, 19 Apr 2025 18:02:24 -0400 Subject: [PATCH 1/6] feat: reorder Movement field in admin view --- src/transactions/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/transactions/admin.py b/src/transactions/admin.py index ec03e17..dc26d92 100644 --- a/src/transactions/admin.py +++ b/src/transactions/admin.py @@ -9,7 +9,7 @@ @admin.register(Movement) class MovementAdmin(admin.ModelAdmin): readonly_fields = ('balance_after',) - list_display = ('type', 'description', 'amount_usd', 'balance_after', 'created_at') # optional, shows these columns in the list view + list_display = ('description', 'type', 'amount_usd', 'balance_after', 'created_at') def get_changeform_initial_data(self, request): return { From 2f09a385d22adc93e45bcaf99820393ed0a0c1e2 Mon Sep 17 00:00:00 2001 From: Gilles <43683714+corp-0@users.noreply.github.com> Date: Sat, 19 Apr 2025 18:03:32 -0400 Subject: [PATCH 2/6] refactor: optimises balance calculation by skipping redundancy --- src/transactions/apps.py | 3 +++ src/transactions/models.py | 31 +++++----------------------- src/transactions/signals.py | 40 +++++++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 26 deletions(-) create mode 100644 src/transactions/signals.py diff --git a/src/transactions/apps.py b/src/transactions/apps.py index f806531..745789b 100644 --- a/src/transactions/apps.py +++ b/src/transactions/apps.py @@ -4,3 +4,6 @@ class TransactionsConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'transactions' + + def ready(self) -> None: + from . import signals # noqa \ No newline at end of file diff --git a/src/transactions/models.py b/src/transactions/models.py index df79553..c352e94 100644 --- a/src/transactions/models.py +++ b/src/transactions/models.py @@ -1,26 +1,8 @@ from decimal import Decimal - from django.utils import timezone - from django.db import models -def recalculate_balances(): - """Recalculate the balance after each movement. This way the balance is updated even if you add a movement that happened in the past.""" - - movements = Movement.objects.all().order_by("created_at") - balance = Decimal('0.00') - - for movement in movements: - if movement.type == 'income': - balance += movement.amount_usd - else: - balance -= movement.amount_usd - - if movement.balance_after != balance: - movement.balance_after = balance - movement.save(update_fields=['balance_after']) - MOVEMENT_TYPES = [ ("income", "Income"), ("expense", "Expense"), @@ -35,19 +17,16 @@ class Movement(models.Model): amount_usd = models.DecimalField(max_digits=10, decimal_places=2) created_at = models.DateTimeField(default=timezone.now) balance_after = models.DecimalField(max_digits=10, decimal_places=2, editable=False) + link = models.URLField(blank=True, null=True) class Meta: - ordering = ['created_at'] + ordering = ['-created_at'] - def save(self, *args, **kwargs): + def save(self, *args, **kwargs) -> None: is_new = self._state.adding - if is_new: - # Temporarily set balance_after to 0 to avoid NULL error - self.balance_after = Decimal("0.00") - - super().save(*args, **kwargs) # Save once so the row exists (with a temporary value) - recalculate_balances() + self.balance_after = Decimal("0.00") # Placeholder + super().save(*args, **kwargs) def __str__(self): return f"{self.created_at.date()} - {self.type.capitalize()}: ${self.amount_usd} - {self.description}" diff --git a/src/transactions/signals.py b/src/transactions/signals.py new file mode 100644 index 0000000..dae1349 --- /dev/null +++ b/src/transactions/signals.py @@ -0,0 +1,40 @@ +from decimal import Decimal +from django.db.models.signals import post_save, post_delete +from django.dispatch import receiver +from .models import Movement +from logging import getLogger + +logger = getLogger(__name__) + +def recalculate_balances() -> None: + """Recalculate balances chronologically after each movement.""" + movements = Movement.objects.all().order_by("created_at") + balance = Decimal('0.00') + to_update: list[Movement] = [] + + for movement in movements.iterator(): + balance += movement.amount_usd if movement.type == 'income' else -movement.amount_usd + + if movement.balance_after != balance: + movement.balance_after = balance + to_update.append(movement) + + Movement.objects.bulk_update(to_update, ['balance_after']) + + +@receiver([post_save, post_delete], sender=Movement) +def movement_changed( + sender: type[Movement], # noqa + instance: Movement, + **kwargs, # swallows raw, using, signal, etc. +) -> None: + # Skip when loaddata is running (raw fixture load) + if kwargs.get("raw"): + logger.debug("Raw fixture load – balance recomputation skipped") + return + + logger.info("Updated balance for movement %s", instance) + try: + recalculate_balances() + except Exception as e: + logger.exception(f"Failed to recalculate balances: {e}") \ No newline at end of file From dff8dfd918689879b1cd7f1ef9a430785d1df599 Mon Sep 17 00:00:00 2001 From: Gilles <43683714+corp-0@users.noreply.github.com> Date: Sat, 19 Apr 2025 18:03:52 -0400 Subject: [PATCH 3/6] feat: show all relevant fields in API response --- src/transactions/api/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/transactions/api/serializers.py b/src/transactions/api/serializers.py index a645d68..318435a 100644 --- a/src/transactions/api/serializers.py +++ b/src/transactions/api/serializers.py @@ -6,7 +6,7 @@ class MoneyMovementSerializer(serializers.ModelSerializer): class Meta: model = Movement - fields = ['id', 'type', 'amount_usd', 'created_at', 'balance_after'] + fields = '__all__' read_only_fields = ['balance_after'] def create(self, validated_data): From 4ddd6b48030f3643e3f953ac905d97b658eaa15c Mon Sep 17 00:00:00 2001 From: Gilles <43683714+corp-0@users.noreply.github.com> Date: Sat, 19 Apr 2025 18:04:18 -0400 Subject: [PATCH 4/6] chore: update migrations --- ...04_alter_movement_options_movement_link.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/transactions/migrations/0004_alter_movement_options_movement_link.py diff --git a/src/transactions/migrations/0004_alter_movement_options_movement_link.py b/src/transactions/migrations/0004_alter_movement_options_movement_link.py new file mode 100644 index 0000000..c4376c5 --- /dev/null +++ b/src/transactions/migrations/0004_alter_movement_options_movement_link.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1.7 on 2025-04-19 21:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('transactions', '0003_movementtemplate'), + ] + + operations = [ + migrations.AlterModelOptions( + name='movement', + options={'ordering': ['-created_at']}, + ), + migrations.AddField( + model_name='movement', + name='link', + field=models.URLField(blank=True, null=True), + ), + ] From 6c7e68cc84104386e0811d56ebd03ddf127ecd39 Mon Sep 17 00:00:00 2001 From: Gilles <43683714+corp-0@users.noreply.github.com> Date: Sat, 19 Apr 2025 18:05:07 -0400 Subject: [PATCH 5/6] feat: allow to be requested from anywhere --- src/ledger/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ledger/settings.py b/src/ledger/settings.py index 090f293..ca99493 100644 --- a/src/ledger/settings.py +++ b/src/ledger/settings.py @@ -26,7 +26,7 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = bool(os.environ.get("DJANGO_DEBUG", default="1")) -ALLOWED_HOSTS = ["*"] if DEBUG else ["localhost", "127.0.0.1", "ledger.unitystation.org"] +ALLOWED_HOSTS = ["*"] # CSRF CSRF_TRUSTED_ORIGINS = ['https://ledger.unitystation.org'] From d2769ba0e2ea64e668f4c319f49098071685bdcf Mon Sep 17 00:00:00 2001 From: Gilles <43683714+corp-0@users.noreply.github.com> Date: Sat, 19 Apr 2025 18:11:38 -0400 Subject: [PATCH 6/6] chore: make CICD auto discover tests instead of using a tests/ folder --- .github/workflows/main.yml | 100 ++++++++++++++++++------------------- 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c6a5283..06ff2d9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,75 +14,75 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v3 - with: - version: "0.4.24" - enable-cache: true + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + with: + version: "0.4.24" + enable-cache: true - - name: pre-commit cache key - run: echo "PY=$(python -VV | sha256sum | cut -d' ' -f1)" >> $GITHUB_ENV + - name: pre-commit cache key + run: echo "PY=$(python -VV | sha256sum | cut -d' ' -f1)" >> $GITHUB_ENV - - uses: actions/cache@v4 - with: - path: ~/.cache/pre-commit - key: pre-commit|${{ env.PY }}|${{ hashFiles('.pre-commit-config.yaml') }} + - uses: actions/cache@v4 + with: + path: ~/.cache/pre-commit + key: pre-commit|${{ env.PY }}|${{ hashFiles('.pre-commit-config.yaml') }} - - name: Install Python - run: uv python install + - name: Install Python + run: uv python install - - name: install dependencies - run: uv sync + - name: install dependencies + run: uv sync # https://github.com/typeddjango/django-stubs/issues/458 - - name: create .env file - run: cp example.env .env + - name: create .env file + run: cp example.env .env unit_test: needs: [ lint ] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v3 - with: - version: "0.4.24" - enable-cache: true + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + with: + version: "0.4.24" + enable-cache: true - - name: Set up Python - run: uv python install + - name: Set up Python + run: uv python install - - name: install dependencies - run: uv sync + - name: install dependencies + run: uv sync - - name: create .env file - run: cp example.env .env + - name: create .env file + run: cp example.env .env - - name: Run tests - env: - SECRET_KEY: secret - DB_ENGINE: django.db.backends.sqlite3 - run: | - cd src - uv run manage.py makemigrations --check - uv run manage.py migrate - uv run manage.py test tests/ + - name: Run tests + env: + SECRET_KEY: secret + DB_ENGINE: django.db.backends.sqlite3 + run: | + cd src + uv run manage.py makemigrations --check + uv run manage.py migrate + uv run manage.py test docker: needs: [ lint, unit_test ] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Build docker image - run: | - docker pull $IMAGE_NAME - docker build --pull --cache-from $IMAGE_NAME -t $IMAGE_NAME:latest . + - uses: actions/checkout@v4 + - name: Build docker image + run: | + docker pull $IMAGE_NAME + docker build --pull --cache-from $IMAGE_NAME -t $IMAGE_NAME:latest . - - name: Log in into Docker Hub - if: ${{ github.event_name == 'push' }} - run: | - echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin + - name: Log in into Docker Hub + if: ${{ github.event_name == 'push' }} + run: | + echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin - - name: Push image to registry - if: ${{ github.event_name == 'push' }} - run: | - docker push $IMAGE_NAME + - name: Push image to registry + if: ${{ github.event_name == 'push' }} + run: | + docker push $IMAGE_NAME