Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 50 additions & 50 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion src/ledger/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
2 changes: 1 addition & 1 deletion src/transactions/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/transactions/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
3 changes: 3 additions & 0 deletions src/transactions/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@
class TransactionsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'transactions'

def ready(self) -> None:
from . import signals # noqa
Original file line number Diff line number Diff line change
@@ -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),
),
]
31 changes: 5 additions & 26 deletions src/transactions/models.py
Original file line number Diff line number Diff line change
@@ -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"),
Expand All @@ -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}"
Expand Down
40 changes: 40 additions & 0 deletions src/transactions/signals.py
Original file line number Diff line number Diff line change
@@ -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}")
Loading