From 527e2016f08a7d2dbed971703bbdfb0483851c83 Mon Sep 17 00:00:00 2001 From: opierre Date: Sat, 6 Dec 2025 00:55:46 +0100 Subject: [PATCH 01/13] refactor: remove django component --- budgetter_server/budgetter/__init__.py | 0 budgetter_server/budgetter/asgi.py | 15 - budgetter_server/budgetter/settings.py | 147 --------- budgetter_server/budgetter/urls.py | 45 --- budgetter_server/budgetter/wsgi.py | 16 - budgetter_server/dashboard/__init__.py | 0 budgetter_server/dashboard/admin.py | 22 -- budgetter_server/dashboard/apps.py | 15 - .../dashboard/fixtures/fr_banks.json | 280 ------------------ budgetter_server/dashboard/models.py | 104 ------- budgetter_server/dashboard/serializers.py | 57 ---- budgetter_server/dashboard/urls.py | 12 - budgetter_server/dashboard/views.py | 51 ---- budgetter_server/manage.py | 21 -- .../media/logo/credit_agricole_sa.svg | 77 ----- budgetter_server/utils/__init__.py | 0 budgetter_server/utils/ai_categorizer.py | 108 ------- budgetter_server/utils/categorizer.py | 43 --- budgetter_server/utils/ofx_handler.py | 47 --- 19 files changed, 1060 deletions(-) delete mode 100644 budgetter_server/budgetter/__init__.py delete mode 100644 budgetter_server/budgetter/asgi.py delete mode 100644 budgetter_server/budgetter/settings.py delete mode 100644 budgetter_server/budgetter/urls.py delete mode 100644 budgetter_server/budgetter/wsgi.py delete mode 100644 budgetter_server/dashboard/__init__.py delete mode 100644 budgetter_server/dashboard/admin.py delete mode 100644 budgetter_server/dashboard/apps.py delete mode 100644 budgetter_server/dashboard/fixtures/fr_banks.json delete mode 100644 budgetter_server/dashboard/models.py delete mode 100644 budgetter_server/dashboard/serializers.py delete mode 100644 budgetter_server/dashboard/urls.py delete mode 100644 budgetter_server/dashboard/views.py delete mode 100644 budgetter_server/manage.py delete mode 100644 budgetter_server/media/logo/credit_agricole_sa.svg delete mode 100644 budgetter_server/utils/__init__.py delete mode 100644 budgetter_server/utils/ai_categorizer.py delete mode 100644 budgetter_server/utils/categorizer.py delete mode 100644 budgetter_server/utils/ofx_handler.py diff --git a/budgetter_server/budgetter/__init__.py b/budgetter_server/budgetter/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/budgetter_server/budgetter/asgi.py b/budgetter_server/budgetter/asgi.py deleted file mode 100644 index c5d7eba..0000000 --- a/budgetter_server/budgetter/asgi.py +++ /dev/null @@ -1,15 +0,0 @@ -import os - -from django.core.asgi import get_asgi_application - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'budgetter.settings') -# Initialize Django ASGI application early to ensure the AppRegistry -# is populated before importing code that may import ORM models. -django_asgi_app = get_asgi_application() - -from channels.routing import ProtocolTypeRouter - -application = ProtocolTypeRouter({ - # Django's ASGI application to handle traditional HTTP requests - "http": django_asgi_app, -}) diff --git a/budgetter_server/budgetter/settings.py b/budgetter_server/budgetter/settings.py deleted file mode 100644 index 39ed1de..0000000 --- a/budgetter_server/budgetter/settings.py +++ /dev/null @@ -1,147 +0,0 @@ -""" -Django settings for server project. - -Generated by 'django-admin startproject' using Django 2.2.6. - -For more information on this file, see -https://docs.djangoproject.com/en/2.2/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/2.2/ref/settings/ -""" - -import os -from pathlib import Path - -from budgetter import database - - -# Build paths inside the project like this: os.path.join(BASE_DIR, ...) -BASE_DIR = Path(__file__).resolve().parent.parent - -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = database.SECRET_KEY - -ALLOWED_HOSTS = database.ALLOWED_HOSTS - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True - -# Database -# https://docs.djangoproject.com/en/2.2/ref/settings/#databases - -DATABASES = database.DATABASE - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ - - -# Application definition - -INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'rest_framework', - 'django_extensions', - 'drf_yasg', - 'django_filters', - # APP - 'dashboard.apps.DashboardConfig' -] - -MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware' -] - -INTERNAL_IPS = ['127.0.0.1'] - -ROOT_URLCONF = 'budgetter.urls' - -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [os.path.join(BASE_DIR, 'templates')], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - ], - }, - }, -] - -WSGI_APPLICATION = 'budgetter.wsgi.application' - -# Password validation -# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators - -AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', - }, -] - - -# Internationalization -# https://docs.djangoproject.com/en/2.2/topics/i18n/ - -LANGUAGE_CODE = 'en-us' - -TIME_ZONE = 'Europe/Paris' - -USE_I18N = True - -USE_L10N = True - -USE_TZ = True - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/2.2/howto/static-files/ - -STATIC_URL = '/static/' -STATIC_ROOT = os.path.join(BASE_DIR, 'static') -# STATICFILES_DIRS = [ -# os.path.join(BASE_DIR, 'static_base') -# ] - -REST_FRAMEWORK = { - "DATE_INPUT_FORMATS": ["%Y-%m-%d"], -} - -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' - -# AI Categorization Settings -USE_AI_CATEGORIZATION = True # Set to False to use sklearn instead -AI_CONFIDENCE_THRESHOLD = 0.25 # Minimum confidence (0.0-1.0) to accept prediction - -MEDIA_ROOT = os.path.join(BASE_DIR, 'media') - -ASGI_APPLICATION = 'budgetter.asgi.application' - -CHANNEL_LAYERS = { - "default": { - "BACKEND": "channels.layers.InMemoryChannelLayer" - } -} diff --git a/budgetter_server/budgetter/urls.py b/budgetter_server/budgetter/urls.py deleted file mode 100644 index fb9debe..0000000 --- a/budgetter_server/budgetter/urls.py +++ /dev/null @@ -1,45 +0,0 @@ -"""budgetter URL Configuration - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/2.2/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.urls import include, path - 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) -""" -from django.contrib import admin -from django.urls import path, include, re_path -from rest_framework import permissions -from drf_yasg.views import get_schema_view -from drf_yasg import openapi - -schema_view = get_schema_view( - openapi.Info( - title="Snippets API", - default_version='v1', - description="Test description", - terms_of_service="https://www.google.com/policies/terms/", - contact=openapi.Contact(email="contact@snippets.local"), - license=openapi.License(name="BSD License"), - ), - public=True, - permission_classes=[permissions.AllowAny], -) - -budget_urls = [ - # Budget API - path('api/budget/', include('dashboard.urls')), -] - -urlpatterns = [ - path('api/admin/', admin.site.urls), - re_path(r'^api/swagger/$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), -] - -urlpatterns.extend(budget_urls) diff --git a/budgetter_server/budgetter/wsgi.py b/budgetter_server/budgetter/wsgi.py deleted file mode 100644 index 4f575ac..0000000 --- a/budgetter_server/budgetter/wsgi.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -WSGI config for budgetter project. - -It exposes the WSGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/ -""" - -import os - -from django.core.wsgi import get_wsgi_application - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'budgetter.settings') - -application = get_wsgi_application() diff --git a/budgetter_server/dashboard/__init__.py b/budgetter_server/dashboard/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/budgetter_server/dashboard/admin.py b/budgetter_server/dashboard/admin.py deleted file mode 100644 index c96d1f1..0000000 --- a/budgetter_server/dashboard/admin.py +++ /dev/null @@ -1,22 +0,0 @@ -from django.contrib import admin - -from .models import Bank, Account, Transaction, Category - - -class BankAdmin(admin.ModelAdmin): - list_display = ('name', 'bic', 'swift') - - -class AccountAdmin(admin.ModelAdmin): - list_display = ('id', 'name', 'bank', 'amount', 'color', 'last_update', 'status') - list_filter = ('bank', 'status') - - -class TransactionAdmin(admin.ModelAdmin): - list_display = ('name', 'amount', 'date', 'account', 'mean') - list_filter = ('date', 'account') - -admin.site.register(Bank, BankAdmin) -admin.site.register(Account, AccountAdmin) -admin.site.register(Transaction, TransactionAdmin) -admin.site.register(Category) diff --git a/budgetter_server/dashboard/apps.py b/budgetter_server/dashboard/apps.py deleted file mode 100644 index 9d1c8a2..0000000 --- a/budgetter_server/dashboard/apps.py +++ /dev/null @@ -1,15 +0,0 @@ -from django.apps import AppConfig - - -class DashboardConfig(AppConfig): - name = 'dashboard' - verbose_name = 'Dashboard' - def ready(self): - from django.conf import settings - if getattr(settings, 'USE_AI_CATEGORIZATION', False): - from utils.ai_categorizer import preload_model - import threading - - # Run in a separate thread to avoid blocking startup completely, - # but ensure it starts downloading immediately - threading.Thread(target=preload_model, daemon=True).start() diff --git a/budgetter_server/dashboard/fixtures/fr_banks.json b/budgetter_server/dashboard/fixtures/fr_banks.json deleted file mode 100644 index c5ce1e3..0000000 --- a/budgetter_server/dashboard/fixtures/fr_banks.json +++ /dev/null @@ -1,280 +0,0 @@ -[ - { - "model": "dashboard.bank", - "pk": 1, - "fields": { - "name": "CREDIT AGRICOLE SA", - "swift": "AGRIFRPP", - "bic": [ - "10206", - "11006", - "11206", - "11306", - "11706", - "12206", - "12406", - "12506", - "12906", - "13106", - "13306", - "13506", - "13606", - "13906", - "14406", - "14506", - "14706", - "14806", - "16006", - "16106", - "16606", - "16706", - "16806", - "16906", - "17106", - "17206", - "17806", - "17906", - "18106", - "18206", - "18306", - "18706", - "19106", - "19406", - "19506" - ] - } - }, - { - "model": "dashboard.bank", - "pk": 2, - "fields": { - "name": "AXA BANQUE SA", - "swift": "AXABFRPP", - "bic": [ - "12548" - ] - } - }, - { - "model": "dashboard.bank", - "pk": 3, - "fields": { - "name": "BNP-PARIBAS", - "swift": "BNPAFRPP", - "bic": [ - "30004" - ] - } - }, - { - "model": "dashboard.bank", - "pk": 4, - "fields": { - "name": "BRED BANQUE POPULAIRE", - "swift": "BREDFRPP", - "bic": [ - "10107" - ] - } - }, - { - "model": "dashboard.bank", - "pk": 5, - "fields": { - "name": "BANQUE POPULAIRE", - "swift": "CCBPFRPP", - "bic": [ - "10907", - "10807", - "16807", - "13507", - "14607", - "10207", - "14707", - "13807", - "16607", - "16707", - "17807", - "18707" - ] - } - }, - { - "model": "dashboard.bank", - "pk": 6, - "fields": { - "name": "CREDIT COOPERATIF", - "swift": "CCOPFRPP", - "bic": [ - "42559" - ] - } - }, - { - "model": "dashboard.bank", - "pk": 7, - "fields": { - "name": "CAISSE D'EPARGNE", - "swift": "CEPAFRPP", - "bic": [ - "16275", - "17515", - "11315", - "11425", - "12135", - "13135", - "13335", - "13485", - "13825", - "14265", - "14445", - "14505", - "15135", - "16705", - "18315", - "18715" - ] - } - }, - { - "model": "dashboard.bank", - "pk": 8, - "fields": { - "name": "CREDIT MUTUEL ARKEA", - "swift": "CMBRFR2B", - "bic": [ - "15589", - "15589" - ] - } - }, - { - "model": "dashboard.bank", - "pk": 9, - "fields": { - "name": "CREDIT MUTUEL", - "swift": "CMCIFR2A", - "bic": [ - "10278" - ] - } - }, - { - "model": "dashboard.bank", - "pk": 10, - "fields": { - "name": "CREDIT MUTUEL-CIC BANQUES", - "swift": "CMCIFRP1MON", - "bic": [ - "14690" - ] - } - }, - { - "model": "dashboard.bank", - "pk": 11, - "fields": { - "name": "CIC BANQUES", - "swift": "CMCIFRPP", - "bic": [ - "10096" - ] - } - }, - { - "model": "dashboard.bank", - "pk": 12, - "fields": { - "name": "ARKEA DIRECT BANK", - "swift": "FTNOFRP1", - "bic": [ - "14518" - ] - } - }, - { - "model": "dashboard.bank", - "pk": 13, - "fields": { - "name": "ORANGE BANK", - "swift": "GPBAFRPP", - "bic": [ - "18370" - ] - } - }, - { - "model": "dashboard.bank", - "pk": 14, - "fields": { - "name": "PFS CARD SERVICES IRELAND LIMITED", - "swift": "PRNSFRP1", - "bic": [ - "21833" - ] - } - }, - { - "model": "dashboard.bank", - "pk": 15, - "fields": { - "name": "LA BANQUE POSTALE", - "swift": "PSSTFRPP", - "bic": [ - "10011", - "20041", - "20041", - "20041", - "20041", - "20041", - "20041", - "20041", - "20041", - "20041", - "20041", - "20041", - "20041", - "20041", - "20041", - "20041", - "20041", - "20041", - "20041", - "20041" - ] - } - }, - { - "model": "dashboard.bank", - "pk": 16, - "fields": { - "name": "CARREFOUR BANQUE", - "swift": "SOAPFR22", - "bic": [ - "19870" - ] - } - }, - { - "model": "dashboard.bank", - "pk": 17, - "fields": { - "name": "SOCIETE GENERALE", - "swift": "SOGEFRPP", - "bic": [ - "30003" - ] - } - }, - { - "model": "dashboard.bank", - "pk": 18, - "fields": { - "name": "SOCRAM BANQUE", - "swift": "SORMFR2N", - "bic": [ - "12280" - ] - } - } -] \ No newline at end of file diff --git a/budgetter_server/dashboard/models.py b/budgetter_server/dashboard/models.py deleted file mode 100644 index 1cf8afe..0000000 --- a/budgetter_server/dashboard/models.py +++ /dev/null @@ -1,104 +0,0 @@ -import datetime - -from django.utils.translation import gettext as _ -from django.db import models - - -class Mean(models.TextChoices): - CARD = 'CARD' - CASH = 'CASH' - TRANSFER = 'TRANSFER' - - -class TransactionType(models.TextChoices): - EXPENSES = 'EXPENSES' - INCOME = 'INCOME' - INTERNAL = 'INTERNAL' - - -class AccountType(models.TextChoices): - CREDIT_CARD = "CREDIT CARD" - - -class Status(models.TextChoices): - ACTIVE = 'ACTIVE' - CLOSED = 'CLOSED' - - -class Bank(models.Model): - name = models.CharField(max_length=1000, default='') - swift = models.CharField(max_length=1000, default='') - bic = models.JSONField(default=list) - - def __str__(self): - return f"{self.name}" - - -class Account(models.Model): - name = models.CharField(_("Name"), max_length=1000, default='', null=True, blank=True) - account_id = models.CharField(_("AccountID"), max_length=1000, default='', unique=True) - account_type = models.CharField(_("AccountType"), choices=AccountType.choices, default=AccountType.CREDIT_CARD) - bank = models.ForeignKey("Bank", on_delete=models.CASCADE) - amount = models.FloatField(_("Amount"), default=0) - color = models.CharField(_("Color"), max_length=1000, default='') - last_update = models.DateField(_("Date"), default=datetime.date.today) - status = models.CharField(_("State"), choices=Status.choices, default=Status.ACTIVE) - - def __str__(self): - return f"{self.account_id}" - - -class Category(models.Model): - name = models.CharField(max_length=1000, default='') - - def __str__(self): - return self.name - - -class CategorizationRule(models.Model): - keywords = models.CharField(max_length=1000, help_text="Comma-separated keywords or regex") - category = models.ForeignKey(Category, on_delete=models.CASCADE) - transaction_type = models.CharField( - max_length=1000, - choices=TransactionType.choices, - null=True, - blank=True, - help_text="Optional filter by transaction type" - ) - - def matches(self, name: str, memo: str) -> bool: - """ - Check if rule matches transaction name or memo - """ - import re - name = (name or '').lower() - memo = (memo or '').lower() - keywords = [kw.strip().lower() for kw in self.keywords.split(',')] - - for keyword in keywords: - # Try exact match - if keyword in name or keyword in memo: - return True - # Try regex - try: - if re.search(keyword, name) or re.search(keyword, memo): - return True - except re.error: - continue - return False - - def __str__(self): - return f"Rule for {self.category.name}: {self.keywords}" - - -class Transaction(models.Model): - name = models.CharField(max_length=1000, default='') - amount = models.DecimalField(max_digits=11, decimal_places=2) - date = models.DateField() - account = models.ForeignKey("Account", on_delete=models.CASCADE) - category = models.ForeignKey("Category", on_delete=models.CASCADE, null=True, blank=True) - comment = models.CharField(max_length=4000, default='', blank=True) - mean = models.CharField(max_length=1000, choices=Mean.choices, default=Mean.CARD) - transaction_type = models.CharField(max_length=1000, choices=TransactionType.choices, default=TransactionType.EXPENSES) - reference = models.CharField(max_length=1000, default='', unique=True) - diff --git a/budgetter_server/dashboard/serializers.py b/budgetter_server/dashboard/serializers.py deleted file mode 100644 index 555d324..0000000 --- a/budgetter_server/dashboard/serializers.py +++ /dev/null @@ -1,57 +0,0 @@ -import os.path -from typing import Union - -from rest_framework import serializers -from rest_framework.fields import SerializerMethodField -from rest_framework.serializers import ModelSerializer - -from budgetter.settings import MEDIA_ROOT -from .models import Bank, Account, Category, Transaction - - -class BankSerializer(ModelSerializer): - svg_content = SerializerMethodField(read_only=True) - - class Meta: - model = Bank - fields = '__all__' - - @staticmethod - def get_svg_content(bank_obj: Bank) -> Union[str, None]: - """ - Return SVG content from logo - - :param bank_obj: bank instance - :return: SVG content as string - """ - - try: - bank_logo = os.path.join(MEDIA_ROOT, "logo", f"{bank_obj.name.lower().replace(' ', '_')}.svg") - with open(bank_logo, 'r') as svg_file: - svg_data = svg_file.read() - return svg_data - except FileNotFoundError: - return None - - -class AccountSerializer(ModelSerializer): - bank = serializers.StringRelatedField() - - class Meta: - model = Account - fields = '__all__' - - -class CategorySerializer(ModelSerializer): - class Meta: - model = Category - fields = '__all__' - - -class TransactionSerializer(ModelSerializer): - date = serializers.DateField(format="%Y-%m-%d", required=False) # ISO 8601 format - account = serializers.StringRelatedField() - - class Meta: - model = Transaction - fields = '__all__' diff --git a/budgetter_server/dashboard/urls.py b/budgetter_server/dashboard/urls.py deleted file mode 100644 index c4089b7..0000000 --- a/budgetter_server/dashboard/urls.py +++ /dev/null @@ -1,12 +0,0 @@ -from rest_framework.routers import DefaultRouter - -from . import views - -router = DefaultRouter() - -router.register('bank', views.BankViewSet) -router.register('account', views.AccountViewSet) -router.register('category', views.CategoryViewSet) -router.register('transaction', views.TransactionViewSet) - -urlpatterns = router.urls diff --git a/budgetter_server/dashboard/views.py b/budgetter_server/dashboard/views.py deleted file mode 100644 index a6b3ef1..0000000 --- a/budgetter_server/dashboard/views.py +++ /dev/null @@ -1,51 +0,0 @@ -from django_filters.rest_framework import DjangoFilterBackend -from rest_framework import status -from rest_framework.response import Response -from rest_framework.viewsets import ModelViewSet - -from .models import Bank, Account, Category, Transaction -from .serializers import BankSerializer, AccountSerializer, CategorySerializer, TransactionSerializer - - -class BankViewSet(ModelViewSet): - queryset = Bank.objects.all() - serializer_class = BankSerializer - filter_backends = [DjangoFilterBackend] - filterset_fields = ['name'] - - -class AccountViewSet(ModelViewSet): - queryset = Account.objects.all() - serializer_class = AccountSerializer - filter_backends = [DjangoFilterBackend] - filterset_fields = ['name', 'bank'] - - -class CategoryViewSet(ModelViewSet): - queryset = Category.objects.all() - serializer_class = CategorySerializer - filter_backends = [DjangoFilterBackend] - filterset_fields = ['name'] - - -class TransactionViewSet(ModelViewSet): - queryset = Transaction.objects.all() - serializer_class = TransactionSerializer - filter_backends = [DjangoFilterBackend] - filterset_fields = ['name', 'amount', 'date'] - - def create(self, request, *args, **kwargs): - """ - Override create to find matching category first and multiple objects - - :param request: request - :param args: args - :param kwargs: kwargs - :return: Response - """ - - serializer = self.get_serializer(data=request.data, many=isinstance(request.data, list)) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) diff --git a/budgetter_server/manage.py b/budgetter_server/manage.py deleted file mode 100644 index 1c27db2..0000000 --- a/budgetter_server/manage.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python -"""Django's command-line utility for administrative tasks.""" -import os -import sys - - -def main(): - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'budgetter.settings') - try: - from django.core.management import execute_from_command_line - except ImportError as exc: - raise ImportError( - "Couldn't import Django. Are you sure it's installed and " - "available on your PYTHONPATH environment variable? Did you " - "forget to activate a virtual environment?" - ) from exc - execute_from_command_line(sys.argv) - - -if __name__ == '__main__': - main() diff --git a/budgetter_server/media/logo/credit_agricole_sa.svg b/budgetter_server/media/logo/credit_agricole_sa.svg deleted file mode 100644 index fbac372..0000000 --- a/budgetter_server/media/logo/credit_agricole_sa.svg +++ /dev/null @@ -1,77 +0,0 @@ - -image/svg+xml diff --git a/budgetter_server/utils/__init__.py b/budgetter_server/utils/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/budgetter_server/utils/ai_categorizer.py b/budgetter_server/utils/ai_categorizer.py deleted file mode 100644 index 8deb892..0000000 --- a/budgetter_server/utils/ai_categorizer.py +++ /dev/null @@ -1,108 +0,0 @@ -from typing import Optional -from transformers import pipeline -from dashboard.models import Category -import logging - -logger = logging.getLogger(__name__) - -_GLOBAL_CLASSIFIER = None - -class AICategorizer: - """ - Transaction categorizer using AI (DistilBART) for zero-shot classification - """ - - def __init__(self, categories: list, confidence_threshold: float = 0.5): - """ - Initialize AI categorizer - - Args: - categories: List of Category objects to classify into - confidence_threshold: Minimum confidence score to accept prediction - """ - self.categories = categories - self.category_names = [cat.name for cat in categories] - self.category_map = {cat.name: cat for cat in categories} - self.confidence_threshold = confidence_threshold - - # Lazy load the model - self._initialize_model() - - def _initialize_model(self): - """Initialize the AI model (cached after first call)""" - global _GLOBAL_CLASSIFIER - if _GLOBAL_CLASSIFIER is None: - try: - logger.info("Loading AI model...") - # Using zero-shot classification pipeline - _GLOBAL_CLASSIFIER = pipeline( - "zero-shot-classification", - model="valhalla/distilbart-mnli-12-1" - ) - logger.info("AI model loaded successfully") - except Exception as e: - logger.error(f"Failed to load AI model: {e}") - _GLOBAL_CLASSIFIER = None - - self.classifier = _GLOBAL_CLASSIFIER - - def predict(self, name: str, memo: str) -> Optional[Category]: - """ - Predict category for a transaction using AI - - Args: - name: Transaction name/description - memo: Transaction memo/note - - Returns: - Category object if prediction confidence is above threshold, else None - """ - if not self.classifier or not self.category_names: - return None - - # Combine name and memo for richer context - text = f"{name} {memo or ''}".strip() - - if not text: - return None - - try: - # Perform zero-shot classification - result = self.classifier( - text, - candidate_labels=self.category_names, - multi_label=False - ) - - # Get top prediction - top_label = result['labels'][0] - top_score = result['scores'][0] - - logger.debug(f"AI prediction: '{text}' -> {top_label} ({top_score:.2f})") - - # Return category if confidence is above threshold - if top_score >= self.confidence_threshold: - return self.category_map.get(top_label) - - return None - - except Exception as e: - logger.error(f"Error during AI prediction: {e}") - return None - -def preload_model(): - """ - Preload the AI model into cache and memory. - This ensures the model is downloaded and ready for use. - """ - global _GLOBAL_CLASSIFIER - try: - if _GLOBAL_CLASSIFIER is None: - logger.info("Preloading AI model...") - _GLOBAL_CLASSIFIER = pipeline( - "zero-shot-classification", - model="valhalla/distilbart-mnli-12-1" - ) - logger.info("AI model preloaded successfully") - except Exception as e: - logger.error(f"Failed to preload AI model: {e}") diff --git a/budgetter_server/utils/categorizer.py b/budgetter_server/utils/categorizer.py deleted file mode 100644 index 6691de7..0000000 --- a/budgetter_server/utils/categorizer.py +++ /dev/null @@ -1,43 +0,0 @@ -import re -from dashboard.models import CategorizationRule, TransactionType -from typing import Optional -from dashboard.models import Category - -def categorize_transaction(transaction_data: dict, ai_categorizer=None) -> Optional[Category]: - """ - Categorizes a transaction based on defined rules or AI. - Priority: Rules → AI - - Args: - transaction_data: Dictionary containing transaction data. - ai_categorizer: Optional AI categorizer instance. - Returns: - Category object or None. - """ - name = (transaction_data.get('name') or '').lower() - memo = (transaction_data.get('memo') or '').lower() - - # Check rules first (highest priority) - rules = CategorizationRule.objects.all() - for rule in rules: - if rule.matches(name, memo): - # If transaction type is specified in rule, verify it matches - if rule.transaction_type: - txn_type = transaction_data.get('transaction_type') - if txn_type and txn_type != rule.transaction_type: - continue - return rule.category - - # If no rule matched, try AI if provided (second priority) - if ai_categorizer: - try: - predicted_category = ai_categorizer.predict( - transaction_data.get('name', ''), - transaction_data.get('memo', '') - ) - if predicted_category: - return predicted_category - except Exception: - pass - - return None diff --git a/budgetter_server/utils/ofx_handler.py b/budgetter_server/utils/ofx_handler.py deleted file mode 100644 index cf6b05a..0000000 --- a/budgetter_server/utils/ofx_handler.py +++ /dev/null @@ -1,47 +0,0 @@ -from ofxtools.Parser import OFXTree -from decimal import Decimal -import datetime - -def parse_ofx(file_obj) -> list[dict]: - """ - Parses an OFX file and returns a list of transactions. - """ - parser = OFXTree() - parser.parse(file_obj) - ofx = parser.convert() - - parsed_data = [] - - if not ofx.statements: - return parsed_data - - for stmt in ofx.statements: - account_data = { - 'account_id': getattr(stmt.account, 'acctid', None), - 'bank_id': getattr(stmt.account, 'bankid', None), - 'account_type': getattr(stmt.account, 'accttype', None), - 'currency': stmt.curdef, - 'balance': getattr(stmt.ledgerbal, 'balamt', None), - 'balance_date': getattr(stmt.ledgerbal, 'dtasof', None), - 'transactions': [] - } - - # Retrieve transactions - txns = stmt.transactions - - for txn in txns: - # Extract relevant fields - transaction_data = { - 'amount': txn.trnamt, - 'date': txn.dtposted.date(), - 'name': txn.name or txn.memo or "Unknown", - 'reference': txn.fitid, - 'transaction_type': txn.trntype, - 'memo': txn.memo, - 'account_id': account_data['account_id'] - } - account_data['transactions'].append(transaction_data) - - parsed_data.append(account_data) - - return parsed_data From 6ac577eafc97496380b93cf1b1f947d69c8adfd4 Mon Sep 17 00:00:00 2001 From: opierre Date: Sat, 6 Dec 2025 00:56:34 +0100 Subject: [PATCH 02/13] feat: add endpoints --- budgetter_server/api/v1/endpoints/__init__.py | 0 budgetter_server/api/v1/endpoints/accounts.py | 78 +++++++++++++++++++ .../api/v1/endpoints/transactions.py | 53 +++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 budgetter_server/api/v1/endpoints/__init__.py create mode 100644 budgetter_server/api/v1/endpoints/accounts.py create mode 100644 budgetter_server/api/v1/endpoints/transactions.py diff --git a/budgetter_server/api/v1/endpoints/__init__.py b/budgetter_server/api/v1/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/budgetter_server/api/v1/endpoints/accounts.py b/budgetter_server/api/v1/endpoints/accounts.py new file mode 100644 index 0000000..0edea89 --- /dev/null +++ b/budgetter_server/api/v1/endpoints/accounts.py @@ -0,0 +1,78 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlmodel import Session, select + +from db.session import get_session +from models import Account +from schemas.account import AccountCreate, AccountRead + +router = APIRouter() + + +@router.post("/", response_model=AccountRead) +def create_account( + *, + session: Session = Depends(get_session), + account: AccountCreate +) -> Account: + """ + Create a new account. + + Args: + session: Database session dependency. + account: Account creation data. + + Returns: + Account: The created account object. + """ + db_account = Account.model_validate(account) + session.add(db_account) + session.commit() + session.refresh(db_account) + return db_account + + +@router.get("/", response_model=list[AccountRead]) +def read_accounts( + *, + session: Session = Depends(get_session), + offset: int = 0, + limit: int = 100 +) -> list[Account]: + """ + Retrieve a list of accounts. + + Args: + session: Database session dependency. + offset: Number of records to skip (pagination). + limit: Maximum number of records to return (pagination). + + Returns: + list[Account]: List of accounts. + """ + accounts = session.exec(select(Account).offset(offset).limit(limit)).all() + return accounts + + +@router.get("/{account_id}", response_model=AccountRead) +def read_account( + *, + session: Session = Depends(get_session), + account_id: int +) -> Account: + """ + Retrieve a specific account by ID. + + Args: + session: Database session dependency. + account_id: ID of the account to retrieve. + + Returns: + Account: The requested account. + + Raises: + HTTPException: If the account is not found. + """ + account = session.get(Account, account_id) + if not account: + raise HTTPException(status_code=404, detail="Account not found") + return account diff --git a/budgetter_server/api/v1/endpoints/transactions.py b/budgetter_server/api/v1/endpoints/transactions.py new file mode 100644 index 0000000..8b155d4 --- /dev/null +++ b/budgetter_server/api/v1/endpoints/transactions.py @@ -0,0 +1,53 @@ +from fastapi import APIRouter, Depends +from sqlmodel import Session, select + +from db.session import get_session +from models import Transaction +from schemas.transaction import TransactionCreate, TransactionRead + +router = APIRouter() + + +@router.post("/", response_model=TransactionRead) +def create_transaction( + *, + session: Session = Depends(get_session), + transaction: TransactionCreate +) -> Transaction: + """ + Create a new transaction. + + Args: + session: Database session dependency. + transaction: Transaction creation data. + + Returns: + Transaction: The created transaction object. + """ + db_transaction = Transaction.model_validate(transaction) + session.add(db_transaction) + session.commit() + session.refresh(db_transaction) + return db_transaction + + +@router.get("/", response_model=list[TransactionRead]) +def read_transactions( + *, + session: Session = Depends(get_session), + offset: int = 0, + limit: int = 100 +) -> list[Transaction]: + """ + Retrieve a list of transactions. + + Args: + session: Database session dependency. + offset: Number of records to skip (pagination). + limit: Maximum number of records to return (pagination). + + Returns: + list[Transaction]: List of transactions. + """ + transactions = session.exec(select(Transaction).offset(offset).limit(limit)).all() + return transactions From f80144c6fc8215cc44fe8f4d3b9e48f21fd5aa47 Mon Sep 17 00:00:00 2001 From: opierre Date: Sat, 6 Dec 2025 00:57:03 +0100 Subject: [PATCH 03/13] feat: add urls --- budgetter_server/api/v1/api.py | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 budgetter_server/api/v1/api.py diff --git a/budgetter_server/api/v1/api.py b/budgetter_server/api/v1/api.py new file mode 100644 index 0000000..bf7fea4 --- /dev/null +++ b/budgetter_server/api/v1/api.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +from .endpoints import accounts, transactions + +api_router = APIRouter() +api_router.include_router(accounts.router, prefix="/accounts", tags=["accounts"]) +api_router.include_router(transactions.router, prefix="/transactions", tags=["transactions"]) From 763a5cda669bcd4b15508f21ab4ba51ff72b2f63 Mon Sep 17 00:00:00 2001 From: opierre Date: Sat, 6 Dec 2025 00:57:40 +0100 Subject: [PATCH 04/13] feat: add session handle --- budgetter_server/db/session.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 budgetter_server/db/session.py diff --git a/budgetter_server/db/session.py b/budgetter_server/db/session.py new file mode 100644 index 0000000..b7f29a8 --- /dev/null +++ b/budgetter_server/db/session.py @@ -0,0 +1,28 @@ +from typing import Generator + +from sqlmodel import Session, create_engine, SQLModel + +from core.config import settings + +engine = create_engine(settings.DATABASE_URL) + + +def create_db_and_tables() -> None: + """ + Create the database structure and tables. + + This function uses SQLModel metadata to create tables in the database + that do not already exist. + """ + SQLModel.metadata.create_all(engine) + + +def get_session() -> Generator[Session, None, None]: + """ + Dependency to provide a database session. + + Yields: + Session: A SQLModel database session. + """ + with Session(engine) as session: + yield session From ae864ddb841c0a74515d6ab666e4e764b04d1d10 Mon Sep 17 00:00:00 2001 From: opierre Date: Sat, 6 Dec 2025 00:58:40 +0100 Subject: [PATCH 05/13] feat: add sqlmodels --- budgetter_server/models/__init__.py | 11 ++ budgetter_server/models/sql_models.py | 183 ++++++++++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 budgetter_server/models/__init__.py create mode 100644 budgetter_server/models/sql_models.py diff --git a/budgetter_server/models/__init__.py b/budgetter_server/models/__init__.py new file mode 100644 index 0000000..365d368 --- /dev/null +++ b/budgetter_server/models/__init__.py @@ -0,0 +1,11 @@ +from .sql_models import ( + Bank, + Account, + Category, + CategorizationRule, + Transaction, + Mean, + TransactionType, + AccountType, + Status +) diff --git a/budgetter_server/models/sql_models.py b/budgetter_server/models/sql_models.py new file mode 100644 index 0000000..264ed6d --- /dev/null +++ b/budgetter_server/models/sql_models.py @@ -0,0 +1,183 @@ +""" +SQLModel database models for the Budgetter application. +""" +from enum import StrEnum +import datetime +from decimal import Decimal +from typing import Optional + +from sqlalchemy import JSON +from sqlmodel import Field, Relationship, SQLModel, Column + + +class Mean(StrEnum): + """Enumeration for transaction means.""" + CARD = 'CARD' + CASH = 'CASH' + TRANSFER = 'TRANSFER' + + +class TransactionType(StrEnum): + """Enumeration for transaction types.""" + EXPENSES = 'EXPENSES' + INCOME = 'INCOME' + INTERNAL = 'INTERNAL' + + +class AccountType(StrEnum): + """Enumeration for account types.""" + CREDIT_CARD = "CREDIT CARD" + + +class Status(StrEnum): + """Enumeration for account status.""" + ACTIVE = 'ACTIVE' + CLOSED = 'CLOSED' + + +class Bank(SQLModel, table=True): + """ + Represents a banking institution. + + Attributes: + id: Unique identifier for the bank. + name: Name of the bank. + swift: SWIFT code of the bank. + bic: List of BIC codes associated with the bank. + accounts: List of accounts belonging to this bank. + """ + id: Optional[int] = Field(default=None, primary_key=True) + name: str = Field(default='', max_length=1000, description="Name of the bank") + swift: str = Field(default='', max_length=1000, description="SWIFT code") + bic: list[str] = Field(default_factory=list, sa_column=Column(JSON), description="List of BIC codes") + + accounts: list["Account"] = Relationship(back_populates="bank") + + +class Account(SQLModel, table=True): + """ + Represents a financial account. + + Attributes: + id: Unique database identifier. + name: User-friendly name of the account. + account_id: Unique account number/identifier from the bank. + account_type: Type of the account (e.g., Credit Card). + bank_id: Foreign key to the Bank. + amount: Current balance or amount. + color: Visual color for the account in UI. + last_update: Date of the last update. + status: Active or Closed status. + """ + id: Optional[int] = Field(default=None, primary_key=True) + name: Optional[str] = Field(default='', max_length=1000, description="Account name") + account_id: str = Field(default='', max_length=1000, unique=True, index=True, description="Unique account identifier") + account_type: AccountType = Field(default=AccountType.CREDIT_CARD, description="Type of account") + + bank_id: Optional[int] = Field(default=None, foreign_key="bank.id", description="ID of the associated bank") + bank: Optional[Bank] = Relationship(back_populates="accounts") + + amount: float = Field(default=0.0, description="Current balance") + color: str = Field(default='', max_length=1000, description="Display color") + last_update: datetime.date = Field(default_factory=datetime.date.today, description="Last updated date") + status: Status = Field(default=Status.ACTIVE, description="Account status") + + transactions: list["Transaction"] = Relationship(back_populates="account") + + +class Category(SQLModel, table=True): + """ + Represents a transaction category. + + Attributes: + id: Unique identifier. + name: Name of the category. + """ + id: Optional[int] = Field(default=None, primary_key=True) + name: str = Field(default='', max_length=1000, description="Category name") + + categorization_rules: list["CategorizationRule"] = Relationship(back_populates="category") + transactions: list["Transaction"] = Relationship(back_populates="category") + + +class CategorizationRule(SQLModel, table=True): + """ + Rules for automatically categorizing transactions. + + Attributes: + id: Unique identifier. + keywords: Comma-separated keywords or regex patterns. + category_id: ID of the category to assign. + transaction_type: Optional filter for transaction type. + """ + id: Optional[int] = Field(default=None, primary_key=True) + keywords: str = Field(max_length=1000, description="Comma-separated keywords or regex") + + category_id: Optional[int] = Field(default=None, foreign_key="category.id", description="Target category ID") + category: Optional[Category] = Relationship(back_populates="categorization_rules") + + transaction_type: Optional[TransactionType] = Field( + default=None, + description="Optional filter by transaction type" + ) + + def matches(self, name: str, memo: str) -> bool: + """ + Check if the rule matches a transaction based on name or memo. + + Args: + name: The name/payee of the transaction. + memo: The description/memo of the transaction. + + Returns: + bool: True if keywords match, False otherwise. + """ + import re + name_str = (name or '').lower() + memo_str = (memo or '').lower() + keyword_list = [kw.strip().lower() for kw in self.keywords.split(',')] + + for keyword in keyword_list: + # Try exact match + if keyword in name_str or keyword in memo_str: + return True + # Try regex + try: + if re.search(keyword, name_str) or re.search(keyword, memo_str): + return True + except re.error: + continue + return False + + +class Transaction(SQLModel, table=True): + """ + Represents a financial transaction. + + Attributes: + id: Unique identifier. + name: Transaction name/payee. + amount: Transaction amount. + date: Date of the transaction. + account_id: ID of the account. + category_id: ID of the category. + comment: Optional comment/note. + mean: Mean of payment (Card, Cash, etc.). + transaction_type: Type (Expense, Income, etc.). + reference: Unique reference string. + """ + id: Optional[int] = Field(default=None, primary_key=True) + name: str = Field(default='', max_length=1000, description="Transaction name/payee") + amount: Decimal = Field(max_digits=11, decimal_places=2, description="Transaction amount") + date: datetime.date = Field(description="Date of transaction") + + account_id: Optional[int] = Field(default=None, foreign_key="account.id", description="Account ID") + account: Optional[Account] = Relationship(back_populates="transactions") + + category_id: Optional[int] = Field(default=None, foreign_key="category.id", description="Category ID") + category: Optional[Category] = Relationship(back_populates="transactions") + + comment: str = Field(default='', max_length=4000, description="User comment") + mean: Mean = Field(default=Mean.CARD, description="Payment mean") + transaction_type: TransactionType = Field(default=TransactionType.EXPENSES, description="Type of transaction") + reference: str = Field(default='', max_length=1000, unique=True, index=True, description="Unique reference ID") From adfba532c15b850c22498666710443f7e9ef0a4c Mon Sep 17 00:00:00 2001 From: opierre Date: Sat, 6 Dec 2025 01:00:23 +0100 Subject: [PATCH 06/13] feat: add schemas --- budgetter_server/schemas/account.py | 38 +++++++++++++++++++++++ budgetter_server/schemas/transaction.py | 41 +++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 budgetter_server/schemas/account.py create mode 100644 budgetter_server/schemas/transaction.py diff --git a/budgetter_server/schemas/account.py b/budgetter_server/schemas/account.py new file mode 100644 index 0000000..5716202 --- /dev/null +++ b/budgetter_server/schemas/account.py @@ -0,0 +1,38 @@ +from datetime import date +from typing import Optional + +from pydantic import BaseModel, Field +from models.sql_models import AccountType, Status + +class AccountBase(BaseModel): + """ + Base schema for Account data. + """ + name: Optional[str] = Field(default=None, description="Name of the account") + account_id: str = Field(description="Unique account identifier from the bank") + account_type: AccountType = Field(default=AccountType.CREDIT_CARD, description="Type of the account") + amount: float = Field(default=0.0, description="Current balance") + color: str = Field(default="", description="Color code for UI display") + last_update: date = Field(description="Date of last update") + status: Status = Field(default=Status.ACTIVE, description="Status of the account") + bank_id: Optional[int] = Field(default=None, description="ID of the associated bank") + +class AccountCreate(AccountBase): + """ + Schema for creating a new Account. + """ + pass + +class AccountRead(AccountBase): + """ + Schema for reading Account data (includes ID). + """ + id: int = Field(description="Unique database identifier") + +class AccountUpdate(BaseModel): + """ + Schema for updating an Account. + """ + name: Optional[str] = Field(default=None, description="New name for the account") + amount: Optional[float] = Field(default=None, description="New balance amount") + color: Optional[str] = Field(default=None, description="New color code") diff --git a/budgetter_server/schemas/transaction.py b/budgetter_server/schemas/transaction.py new file mode 100644 index 0000000..3ca1786 --- /dev/null +++ b/budgetter_server/schemas/transaction.py @@ -0,0 +1,41 @@ +import datetime +from decimal import Decimal +from typing import Optional + +from pydantic import BaseModel, Field +from models.sql_models import Mean, TransactionType + +class TransactionBase(BaseModel): + """ + Base schema for Transaction data. + """ + name: str = Field(description="Name or payee of the transaction") + amount: Decimal = Field(description="Transaction amount") + date: datetime.date = Field(description="Date of the transaction") + comment: Optional[str] = Field(default="", description="Optional user comment") + mean: Mean = Field(default=Mean.CARD, description="Payment method used") + transaction_type: TransactionType = Field(default=TransactionType.EXPENSES, description="Type of transaction (Income/Expense)") + reference: str = Field(description="Unique reference ID for the transaction") + account_id: Optional[int] = Field(default=None, description="ID of the associated account") + category_id: Optional[int] = Field(default=None, description="ID of the associated category") + +class TransactionCreate(TransactionBase): + """ + Schema for creating a new Transaction. + """ + pass + +class TransactionRead(TransactionBase): + """ + Schema for reading Transaction data. + """ + id: int = Field(description="Unique database identifier") + +class TransactionUpdate(BaseModel): + """ + Schema for updating a Transaction. + """ + name: Optional[str] = Field(default=None, description="New name") + amount: Optional[Decimal] = Field(default=None, description="New amount") + comment: Optional[str] = Field(default=None, description="New comment") + category_id: Optional[int] = Field(default=None, description="New category ID") From a013144b1a6a8324addf6c340363481fb8af654d Mon Sep 17 00:00:00 2001 From: opierre Date: Sat, 6 Dec 2025 01:00:42 +0100 Subject: [PATCH 07/13] feat: add launcher --- budgetter_server/main.py | 49 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 budgetter_server/main.py diff --git a/budgetter_server/main.py b/budgetter_server/main.py new file mode 100644 index 0000000..3d20fff --- /dev/null +++ b/budgetter_server/main.py @@ -0,0 +1,49 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from starlette.middleware.cors import CORSMiddleware + +from api.v1.api import api_router +from core.config import settings +from db.session import create_db_and_tables +# Import models to ensure they are registered with SQLModel metadata +from models import Bank, Account, Category, Transaction + +@asynccontextmanager +async def lifespan(app: FastAPI): + """ + Lifespan context manager for the FastAPI application. + + Args: + app: The FastAPI application instance. + """ + create_db_and_tables() + yield + +app = FastAPI( + title=settings.PROJECT_NAME, + openapi_url=f"{settings.API_V1_STR}/openapi.json", + lifespan=lifespan, +) + +# Set all CORS enabled origins +if settings.BACKEND_CORS_ORIGINS: + app.add_middleware( + CORSMiddleware, + allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + +app.include_router(api_router, prefix=settings.API_V1_STR) + +@app.get("/") +def read_root() -> dict[str, str]: + """ + Root endpoint for health check. + + Returns: + dict: detailed welcome message. + """ + return {"message": "Welcome to Budgetter Server API"} From 1c6cd5245c1dad7330a25d80aecae6a452b91ac7 Mon Sep 17 00:00:00 2001 From: opierre Date: Sat, 6 Dec 2025 01:07:58 +0100 Subject: [PATCH 08/13] build: remove Django dependencies and add FastAPI ones --- pyproject.toml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9eb2649..72ba242 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,16 +13,14 @@ keywords = ["finance", "budget", "management"] license = { text = "GPL-3.0" } classifiers = [] dependencies = [ - "Django>=5.2.8", - "djangorestframework>=3.16.1", - "django-filter>=25.2", - "django-extensions", - "drf_yasg>=1.21.11", "psycopg[binary]>=3.2.13", - "pytz>=2025.2", "ofxtools>=0.9.5", "transformers>=4.30.0", "torch>=2.0.0", + "fastapi>=0.100.0", + "uvicorn>=0.20.0", + "sqlmodel>=0.0.14", + "pydantic-settings>=2.0.0", ] [project.urls] From 800a2a982be6696f5548bd4638b7ff49e9e73b38 Mon Sep 17 00:00:00 2001 From: opierre Date: Sat, 6 Dec 2025 11:29:26 +0100 Subject: [PATCH 09/13] core: move database URL to .env --- budgetter_server/core/config.py | 26 ++++++++++++++++++++++++++ budgetter_server/main.py | 1 - budgetter_server/models/sql_models.py | 3 --- 3 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 budgetter_server/core/config.py diff --git a/budgetter_server/core/config.py b/budgetter_server/core/config.py new file mode 100644 index 0000000..82d1656 --- /dev/null +++ b/budgetter_server/core/config.py @@ -0,0 +1,26 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """ + Application settings and configuration. + + Attributes: + PROJECT_NAME: Name of the project. + API_V1_STR: API version prefix. + DATABASE_URL: Database connection string. + BACKEND_CORS_ORIGINS: List of allowed CORS origins. + """ + PROJECT_NAME: str = "Budgetter Server" + API_V1_STR: str = "/api/v1" + + # Database + DATABASE_URL: str + + # CORS + BACKEND_CORS_ORIGINS: list[str] = ["*"] + + model_config = SettingsConfigDict(case_sensitive=True, env_file=".env") + + +settings = Settings() diff --git a/budgetter_server/main.py b/budgetter_server/main.py index 3d20fff..6ddcb04 100644 --- a/budgetter_server/main.py +++ b/budgetter_server/main.py @@ -6,7 +6,6 @@ from api.v1.api import api_router from core.config import settings from db.session import create_db_and_tables -# Import models to ensure they are registered with SQLModel metadata from models import Bank, Account, Category, Transaction @asynccontextmanager diff --git a/budgetter_server/models/sql_models.py b/budgetter_server/models/sql_models.py index 264ed6d..cbaa7df 100644 --- a/budgetter_server/models/sql_models.py +++ b/budgetter_server/models/sql_models.py @@ -1,6 +1,3 @@ -""" -SQLModel database models for the Budgetter application. -""" from enum import StrEnum import datetime from decimal import Decimal From 124539e54ee1dd683384e44c23d0f03fb27f6915 Mon Sep 17 00:00:00 2001 From: opierre Date: Sat, 6 Dec 2025 13:27:35 +0100 Subject: [PATCH 10/13] fix: update relative imports --- budgetter_server/api/v1/api.py | 2 +- budgetter_server/api/v1/endpoints/accounts.py | 6 +++--- budgetter_server/api/v1/endpoints/transactions.py | 6 +++--- budgetter_server/db/session.py | 2 +- budgetter_server/main.py | 8 ++++---- budgetter_server/schemas/account.py | 2 +- budgetter_server/schemas/transaction.py | 2 +- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/budgetter_server/api/v1/api.py b/budgetter_server/api/v1/api.py index bf7fea4..9eefa7c 100644 --- a/budgetter_server/api/v1/api.py +++ b/budgetter_server/api/v1/api.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from .endpoints import accounts, transactions +from budgetter_server.api.v1.endpoints import accounts, transactions api_router = APIRouter() api_router.include_router(accounts.router, prefix="/accounts", tags=["accounts"]) diff --git a/budgetter_server/api/v1/endpoints/accounts.py b/budgetter_server/api/v1/endpoints/accounts.py index 0edea89..9a5a634 100644 --- a/budgetter_server/api/v1/endpoints/accounts.py +++ b/budgetter_server/api/v1/endpoints/accounts.py @@ -1,9 +1,9 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlmodel import Session, select -from db.session import get_session -from models import Account -from schemas.account import AccountCreate, AccountRead +from budgetter_server.db.session import get_session +from budgetter_server.models import Account +from budgetter_server.schemas.account import AccountCreate, AccountRead router = APIRouter() diff --git a/budgetter_server/api/v1/endpoints/transactions.py b/budgetter_server/api/v1/endpoints/transactions.py index 8b155d4..87f2c54 100644 --- a/budgetter_server/api/v1/endpoints/transactions.py +++ b/budgetter_server/api/v1/endpoints/transactions.py @@ -1,9 +1,9 @@ from fastapi import APIRouter, Depends from sqlmodel import Session, select -from db.session import get_session -from models import Transaction -from schemas.transaction import TransactionCreate, TransactionRead +from budgetter_server.db.session import get_session +from budgetter_server.models import Transaction +from budgetter_server.schemas.transaction import TransactionCreate, TransactionRead router = APIRouter() diff --git a/budgetter_server/db/session.py b/budgetter_server/db/session.py index b7f29a8..e86a74e 100644 --- a/budgetter_server/db/session.py +++ b/budgetter_server/db/session.py @@ -2,7 +2,7 @@ from sqlmodel import Session, create_engine, SQLModel -from core.config import settings +from budgetter_server.core.config import settings engine = create_engine(settings.DATABASE_URL) diff --git a/budgetter_server/main.py b/budgetter_server/main.py index 6ddcb04..2dc2298 100644 --- a/budgetter_server/main.py +++ b/budgetter_server/main.py @@ -3,10 +3,10 @@ from fastapi import FastAPI from starlette.middleware.cors import CORSMiddleware -from api.v1.api import api_router -from core.config import settings -from db.session import create_db_and_tables -from models import Bank, Account, Category, Transaction +from budgetter_server.api.v1.api import api_router +from budgetter_server.core.config import settings +from budgetter_server.db.session import create_db_and_tables +from budgetter_server.models import Bank, Account, Category, Transaction @asynccontextmanager async def lifespan(app: FastAPI): diff --git a/budgetter_server/schemas/account.py b/budgetter_server/schemas/account.py index 5716202..e6e48b8 100644 --- a/budgetter_server/schemas/account.py +++ b/budgetter_server/schemas/account.py @@ -2,7 +2,7 @@ from typing import Optional from pydantic import BaseModel, Field -from models.sql_models import AccountType, Status +from budgetter_server.models.sql_models import AccountType, Status class AccountBase(BaseModel): """ diff --git a/budgetter_server/schemas/transaction.py b/budgetter_server/schemas/transaction.py index 3ca1786..735a8c3 100644 --- a/budgetter_server/schemas/transaction.py +++ b/budgetter_server/schemas/transaction.py @@ -3,7 +3,7 @@ from typing import Optional from pydantic import BaseModel, Field -from models.sql_models import Mean, TransactionType +from budgetter_server.models.sql_models import Mean, TransactionType class TransactionBase(BaseModel): """ From 9f88461747318ff8c27f7969ebb07ade625fc804 Mon Sep 17 00:00:00 2001 From: opierre Date: Sat, 6 Dec 2025 13:31:52 +0100 Subject: [PATCH 11/13] test: add pytest for v1 endpoints --- pyproject.toml | 10 +++++ tests/api/v1/test_accounts.py | 66 ++++++++++++++++++++++++++++++ tests/api/v1/test_transactions.py | 67 +++++++++++++++++++++++++++++++ tests/conftest.py | 60 +++++++++++++++++++++++++++ 4 files changed, 203 insertions(+) create mode 100644 tests/api/v1/test_accounts.py create mode 100644 tests/api/v1/test_transactions.py create mode 100644 tests/conftest.py diff --git a/pyproject.toml b/pyproject.toml index 72ba242..bc27ed2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,9 @@ dependencies = [ "pydantic-settings>=2.0.0", ] +[project.optional-dependencies] +test = ["pytest>=7.12.0", "httpx>=0.28.1", "pytest-cov>=7.0.0"] + [project.urls] repository = "https://github.com/opierre/Budgetter-server" @@ -43,3 +46,10 @@ exclude = ''' ^skeletons/ ) ''' + +[tool.pytest.ini_options] +minversion = "7.0" +addopts = "-ra -q --cov=budgetter_server --cov-report=term-missing --cov-report html:cov_html" +testpaths = ["tests"] +python_files = ["test_*.py"] +pythonpath = [".", "budgetter_server"] diff --git a/tests/api/v1/test_accounts.py b/tests/api/v1/test_accounts.py new file mode 100644 index 0000000..92bc9b9 --- /dev/null +++ b/tests/api/v1/test_accounts.py @@ -0,0 +1,66 @@ +from fastapi.testclient import TestClient +from sqlmodel import Session + +from budgetter_server.schemas.account import AccountCreate +from budgetter_server.models.sql_models import AccountType, Status + + +class TestAccounts: + """ + Test suite for accounts API endpoints. + """ + + def test_create_account(self, client: TestClient) -> None: + """ + Test creating a new account via API. + + Verifies: + - Response status code is 200. + - Response JSON contains expected fields. + """ + response = client.post( + "/api/v1/accounts/", + json={ + "name": "Test Savings", + "account_id": "SAV001", + "account_type": AccountType.CREDIT_CARD, + "amount": 1000.50, + "color": "blue", + "last_update": "2023-10-01", + "status": Status.ACTIVE + } + ) + assert response.status_code == 200 + data = response.json() + assert data["name"] == "Test Savings" + assert data["account_id"] == "SAV001" + assert data["amount"] == 1000.50 + assert "id" in data + + def test_read_accounts(self, client: TestClient) -> None: + """ + Test reading accounts list. + + Verifies: + - Response status code is 200. + - Returns a list of accounts. + """ + # Create an account first + client.post( + "/api/v1/accounts/", + json={ + "name": "Test Checking", + "account_id": "CHK001", + "account_type": AccountType.CREDIT_CARD, + "amount": 500.0, + "color": "green", + "last_update": "2023-10-01", + "status": Status.ACTIVE + } + ) + + response = client.get("/api/v1/accounts/") + assert response.status_code == 200 + data = response.json() + assert len(data) >= 1 + assert data[0]["name"] == "Test Checking" diff --git a/tests/api/v1/test_transactions.py b/tests/api/v1/test_transactions.py new file mode 100644 index 0000000..0d039af --- /dev/null +++ b/tests/api/v1/test_transactions.py @@ -0,0 +1,67 @@ +from fastapi.testclient import TestClient +from sqlmodel import Session, select + +from budgetter_server.models.sql_models import Transaction, Mean, TransactionType, AccountType, Status + + +class TestTransactions: + """ + Test suite for transactions API endpoints. + """ + + def test_create_transaction(self, client: TestClient, session: Session) -> None: + """ + Test creating a new transaction. + + Verifies: + - Response status code is 200. + - Database actually contains the new transaction. + """ + # Create an account first (dependency) + client.post( + "/api/v1/accounts/", + json={ + "name": "Test Account", + "account_id": "ACC123", + "account_type": AccountType.CREDIT_CARD, + "status": Status.ACTIVE, + "amount": 100.0, + "color": "red", + "last_update": "2023-01-01" + } + ) + + # Create transaction + response = client.post( + "/api/v1/transactions/", + json={ + "name": "Groceries", + "amount": 50.25, + "date": "2023-11-15", + "comment": "Weekly shopping", + "mean": Mean.CARD, + "transaction_type": TransactionType.EXPENSES, + "reference": "REF123456", + "account_id": 1 + } + ) + + assert response.status_code == 200 + data = response.json() + assert data["name"] == "Groceries" + assert data["amount"] == "50.25" + + # Verify DB update directly via session + statement = select(Transaction).where(Transaction.reference == "REF123456") + transaction_in_db = session.exec(statement).first() + assert transaction_in_db is not None + assert transaction_in_db.name == "Groceries" + assert transaction_in_db.amount == 50.25 + + def test_read_transactions(self, client: TestClient) -> None: + """ + Test reading transactions list. + """ + response = client.get("/api/v1/transactions/") + assert response.status_code == 200 + assert isinstance(response.json(), list) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4e108f5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,60 @@ +import os +import pytest +from typing import Generator +from fastapi.testclient import TestClient +from sqlmodel import Session, SQLModel, create_engine +from sqlmodel.pool import StaticPool + +# Set env var BEFORE importing app modules to satisfy Settings validation +os.environ["DATABASE_URL"] = "sqlite:///:memory:" + +from budgetter_server.main import app +from budgetter_server.db.session import get_session + + +@pytest.fixture(name="session") +def session_fixture() -> Generator[Session, None, None]: + """ + Creates a new database session for each test. + + This fixture: + 1. Creates an in-memory SQLite engine. + 2. Creates all tables defined in SQLModel metadata. + 3. Yields a session connected to this engine. + 4. Drops all tables after the test. + """ + # Use SQLite in-memory database for tests + engine = create_engine( + "sqlite:///:memory:", # In-memory DB + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + + SQLModel.metadata.create_all(engine) + + with Session(engine) as session: + yield session + + SQLModel.metadata.drop_all(engine) + engine.dispose() + + +@pytest.fixture(name="client") +def client_fixture(session: Session) -> Generator[TestClient, None, None]: + """ + Creates a TestClient with overridden database dependency. + + This ensures that the API uses the test database session + instead of the production one. + """ + def get_session_override(): + return session + + # Override the dependency + app.dependency_overrides[get_session] = get_session_override + + client = TestClient(app) + yield client + + # Clean up overrides + app.dependency_overrides.clear() From f1425e1a9eab3dfb0f6deebc2a8c51972d296473 Mon Sep 17 00:00:00 2001 From: opierre Date: Fri, 12 Dec 2025 19:24:10 +0100 Subject: [PATCH 12/13] refactor: keep only sqlmodels definition and get rid of schemas (useless) --- budgetter_server/api/v1/endpoints/accounts.py | 76 +++++++++- .../api/v1/endpoints/transactions.py | 101 ++++++++++++- budgetter_server/models/__init__.py | 22 +-- budgetter_server/models/sql_models.py | 140 ++++++------------ budgetter_server/schemas/account.py | 38 ----- budgetter_server/schemas/transaction.py | 41 ----- tests/api/v1/test_accounts.py | 3 +- tests/api/v1/test_transactions.py | 2 +- 8 files changed, 224 insertions(+), 199 deletions(-) delete mode 100644 budgetter_server/schemas/account.py delete mode 100644 budgetter_server/schemas/transaction.py diff --git a/budgetter_server/api/v1/endpoints/accounts.py b/budgetter_server/api/v1/endpoints/accounts.py index 9a5a634..17b5154 100644 --- a/budgetter_server/api/v1/endpoints/accounts.py +++ b/budgetter_server/api/v1/endpoints/accounts.py @@ -2,24 +2,23 @@ from sqlmodel import Session, select from budgetter_server.db.session import get_session -from budgetter_server.models import Account -from budgetter_server.schemas.account import AccountCreate, AccountRead +from budgetter_server.models import Account, AccountBase router = APIRouter() -@router.post("/", response_model=AccountRead) +@router.post("/", response_model=Account) def create_account( *, session: Session = Depends(get_session), - account: AccountCreate + account: AccountBase ) -> Account: """ Create a new account. Args: session: Database session dependency. - account: Account creation data. + account: Account creation data (AccountBase). Returns: Account: The created account object. @@ -31,7 +30,7 @@ def create_account( return db_account -@router.get("/", response_model=list[AccountRead]) +@router.get("/", response_model=list[Account], response_model_exclude={"transactions", "bank"}) def read_accounts( *, session: Session = Depends(get_session), @@ -53,7 +52,7 @@ def read_accounts( return accounts -@router.get("/{account_id}", response_model=AccountRead) +@router.get("/{account_id}", response_model=Account, response_model_exclude={"transactions", "bank"}) def read_account( *, session: Session = Depends(get_session), @@ -76,3 +75,66 @@ def read_account( if not account: raise HTTPException(status_code=404, detail="Account not found") return account + + +@router.put("/{account_id}", response_model=Account, response_model_exclude={"transactions", "bank"}) +def update_account( + *, + session: Session = Depends(get_session), + account_id: int, + account_update: AccountBase +) -> Account: + """ + Update an account. + + Args: + session: Database session dependency. + account_id: ID of the account to update. + account_update: Account update data (AccountBase). + + Returns: + Account: The updated account object. + + Raises: + HTTPException: If the account is not found. + """ + db_account = session.get(Account, account_id) + if not db_account: + raise HTTPException(status_code=404, detail="Account not found") + + account_data = account_update.model_dump(exclude_unset=True) + for key, value in account_data.items(): + setattr(db_account, key, value) + + session.add(db_account) + session.commit() + session.refresh(db_account) + return db_account + + +@router.delete("/{account_id}") +def delete_account( + *, + session: Session = Depends(get_session), + account_id: int +): + """ + Delete an account. + + Args: + session: Database session dependency. + account_id: ID of the account to delete. + + Returns: + dict: Confirmation message. + + Raises: + HTTPException: If the account is not found. + """ + account = session.get(Account, account_id) + if not account: + raise HTTPException(status_code=404, detail="Account not found") + + session.delete(account) + session.commit() + return {"ok": True} diff --git a/budgetter_server/api/v1/endpoints/transactions.py b/budgetter_server/api/v1/endpoints/transactions.py index 87f2c54..70225de 100644 --- a/budgetter_server/api/v1/endpoints/transactions.py +++ b/budgetter_server/api/v1/endpoints/transactions.py @@ -1,25 +1,24 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, Query from sqlmodel import Session, select from budgetter_server.db.session import get_session -from budgetter_server.models import Transaction -from budgetter_server.schemas.transaction import TransactionCreate, TransactionRead +from budgetter_server.models import Transaction, TransactionBase router = APIRouter() -@router.post("/", response_model=TransactionRead) +@router.post("/", response_model=Transaction) def create_transaction( *, session: Session = Depends(get_session), - transaction: TransactionCreate + transaction: TransactionBase ) -> Transaction: """ Create a new transaction. Args: session: Database session dependency. - transaction: Transaction creation data. + transaction: Transaction creation data (TransactionBase). Returns: Transaction: The created transaction object. @@ -31,7 +30,7 @@ def create_transaction( return db_transaction -@router.get("/", response_model=list[TransactionRead]) +@router.get("/", response_model=list[Transaction], response_model_exclude={"account", "category"}) def read_transactions( *, session: Session = Depends(get_session), @@ -51,3 +50,91 @@ def read_transactions( """ transactions = session.exec(select(Transaction).offset(offset).limit(limit)).all() return transactions + + +@router.get("/{transaction_id}", response_model=Transaction, response_model_exclude={"account", "category"}) +def read_transaction( + *, + session: Session = Depends(get_session), + transaction_id: int +) -> Transaction: + """ + Retrieve a specific transaction by ID. + + Args: + session: Database session dependency. + transaction_id: ID of the transaction to retrieve. + + Returns: + Transaction: The requested transaction. + + Raises: + HTTPException: If the transaction is not found. + """ + transaction = session.get(Transaction, transaction_id) + if not transaction: + raise HTTPException(status_code=404, detail="Transaction not found") + return transaction + + +@router.put("/{transaction_id}", response_model=Transaction, response_model_exclude={"account", "category"}) +def update_transaction( + *, + session: Session = Depends(get_session), + transaction_id: int, + transaction_update: TransactionBase +) -> Transaction: + """ + Update a transaction. + + Args: + session: Database session dependency. + transaction_id: ID of the transaction to update. + transaction_update: Transaction update data (TransactionBase). + + Returns: + Transaction: The updated transaction object. + + Raises: + HTTPException: If the transaction is not found. + """ + db_transaction = session.get(Transaction, transaction_id) + if not db_transaction: + raise HTTPException(status_code=404, detail="Transaction not found") + + transaction_data = transaction_update.model_dump(exclude_unset=True) + for key, value in transaction_data.items(): + setattr(db_transaction, key, value) + + session.add(db_transaction) + session.commit() + session.refresh(db_transaction) + return db_transaction + + +@router.delete("/{transaction_id}") +def delete_transaction( + *, + session: Session = Depends(get_session), + transaction_id: int +): + """ + Delete a transaction. + + Args: + session: Database session dependency. + transaction_id: ID of the transaction to delete. + + Returns: + dict: Confirmation message. + + Raises: + HTTPException: If the transaction is not found. + """ + transaction = session.get(Transaction, transaction_id) + if not transaction: + raise HTTPException(status_code=404, detail="Transaction not found") + + session.delete(transaction) + session.commit() + return {"ok": True} diff --git a/budgetter_server/models/__init__.py b/budgetter_server/models/__init__.py index 365d368..9a284f5 100644 --- a/budgetter_server/models/__init__.py +++ b/budgetter_server/models/__init__.py @@ -1,11 +1,15 @@ from .sql_models import ( - Bank, - Account, - Category, - CategorizationRule, - Transaction, - Mean, - TransactionType, - AccountType, - Status + Bank, BankBase, + Account, AccountBase, AccountType, Status, + Category, CategoryBase, + CategorizationRule, CategorizationRuleBase, + Transaction, TransactionBase, Mean, TransactionType ) + +__all__ = [ + "Bank", "BankBase", + "Account", "AccountBase", "AccountType", "Status", + "Category", "CategoryBase", + "CategorizationRule", "CategorizationRuleBase", + "Transaction", "TransactionBase", "Mean", "TransactionType" +] diff --git a/budgetter_server/models/sql_models.py b/budgetter_server/models/sql_models.py index cbaa7df..fdf4c23 100644 --- a/budgetter_server/models/sql_models.py +++ b/budgetter_server/models/sql_models.py @@ -1,12 +1,13 @@ from enum import StrEnum import datetime from decimal import Decimal -from typing import Optional +from typing import Optional, List from sqlalchemy import JSON from sqlmodel import Field, Relationship, SQLModel, Column +# Enums class Mean(StrEnum): """Enumeration for transaction means.""" CARD = 'CARD' @@ -24,6 +25,8 @@ class TransactionType(StrEnum): class AccountType(StrEnum): """Enumeration for account types.""" CREDIT_CARD = "CREDIT CARD" + CHECKING = "CHECKING" + SAVINGS = "SAVINGS" class Status(StrEnum): @@ -32,87 +35,52 @@ class Status(StrEnum): CLOSED = 'CLOSED' -class Bank(SQLModel, table=True): - """ - Represents a banking institution. - - Attributes: - id: Unique identifier for the bank. - name: Name of the bank. - swift: SWIFT code of the bank. - bic: List of BIC codes associated with the bank. - accounts: List of accounts belonging to this bank. - """ - id: Optional[int] = Field(default=None, primary_key=True) +class BankBase(SQLModel): name: str = Field(default='', max_length=1000, description="Name of the bank") swift: str = Field(default='', max_length=1000, description="SWIFT code") - bic: list[str] = Field(default_factory=list, sa_column=Column(JSON), description="List of BIC codes") - - accounts: list["Account"] = Relationship(back_populates="bank") - - -class Account(SQLModel, table=True): - """ - Represents a financial account. - - Attributes: - id: Unique database identifier. - name: User-friendly name of the account. - account_id: Unique account number/identifier from the bank. - account_type: Type of the account (e.g., Credit Card). - bank_id: Foreign key to the Bank. - amount: Current balance or amount. - color: Visual color for the account in UI. - last_update: Date of the last update. - status: Active or Closed status. - """ + bic: List[str] = Field(default_factory=list, sa_column=Column(JSON), description="List of BIC codes") + + +class Bank(BankBase, table=True): + """Represents a banking institution.""" id: Optional[int] = Field(default=None, primary_key=True) + accounts: List["Account"] = Relationship(back_populates="bank") + + +class AccountBase(SQLModel): name: Optional[str] = Field(default='', max_length=1000, description="Account name") account_id: str = Field(default='', max_length=1000, unique=True, index=True, description="Unique account identifier") account_type: AccountType = Field(default=AccountType.CREDIT_CARD, description="Type of account") - - bank_id: Optional[int] = Field(default=None, foreign_key="bank.id", description="ID of the associated bank") - bank: Optional[Bank] = Relationship(back_populates="accounts") - amount: float = Field(default=0.0, description="Current balance") color: str = Field(default='', max_length=1000, description="Display color") last_update: datetime.date = Field(default_factory=datetime.date.today, description="Last updated date") status: Status = Field(default=Status.ACTIVE, description="Account status") + bank_id: Optional[int] = Field(default=None, foreign_key="bank.id", description="ID of the associated bank") - transactions: list["Transaction"] = Relationship(back_populates="account") - - -class Category(SQLModel, table=True): - """ - Represents a transaction category. - Attributes: - id: Unique identifier. - name: Name of the category. - """ +class Account(AccountBase, table=True): + """Represents a financial account.""" id: Optional[int] = Field(default=None, primary_key=True) - name: str = Field(default='', max_length=1000, description="Category name") - categorization_rules: list["CategorizationRule"] = Relationship(back_populates="category") - transactions: list["Transaction"] = Relationship(back_populates="category") + bank: Optional[Bank] = Relationship(back_populates="accounts") + transactions: List["Transaction"] = Relationship(back_populates="account") + +class CategoryBase(SQLModel): + name: str = Field(default='', max_length=1000, description="Category name") -class CategorizationRule(SQLModel, table=True): - """ - Rules for automatically categorizing transactions. - Attributes: - id: Unique identifier. - keywords: Comma-separated keywords or regex patterns. - category_id: ID of the category to assign. - transaction_type: Optional filter for transaction type. - """ +class Category(CategoryBase, table=True): + """Represents a transaction category.""" id: Optional[int] = Field(default=None, primary_key=True) - keywords: str = Field(max_length=1000, description="Comma-separated keywords or regex") + categorization_rules: List["CategorizationRule"] = Relationship(back_populates="category") + transactions: List["Transaction"] = Relationship(back_populates="category") + + +class CategorizationRuleBase(SQLModel): + keywords: str = Field(max_length=1000, description="Comma-separated keywords or regex") category_id: Optional[int] = Field(default=None, foreign_key="category.id", description="Target category ID") - category: Optional[Category] = Relationship(back_populates="categorization_rules") - transaction_type: Optional[TransactionType] = Field( default=None, description="Optional filter by transaction type" @@ -121,13 +89,6 @@ class CategorizationRule(SQLModel, table=True): def matches(self, name: str, memo: str) -> bool: """ Check if the rule matches a transaction based on name or memo. - - Args: - name: The name/payee of the transaction. - memo: The description/memo of the transaction. - - Returns: - bool: True if keywords match, False otherwise. """ import re name_str = (name or '').lower() @@ -135,10 +96,8 @@ def matches(self, name: str, memo: str) -> bool: keyword_list = [kw.strip().lower() for kw in self.keywords.split(',')] for keyword in keyword_list: - # Try exact match if keyword in name_str or keyword in memo_str: return True - # Try regex try: if re.search(keyword, name_str) or re.search(keyword, memo_str): return True @@ -147,34 +106,27 @@ def matches(self, name: str, memo: str) -> bool: return False -class Transaction(SQLModel, table=True): - """ - Represents a financial transaction. - - Attributes: - id: Unique identifier. - name: Transaction name/payee. - amount: Transaction amount. - date: Date of the transaction. - account_id: ID of the account. - category_id: ID of the category. - comment: Optional comment/note. - mean: Mean of payment (Card, Cash, etc.). - transaction_type: Type (Expense, Income, etc.). - reference: Unique reference string. - """ +class CategorizationRule(CategorizationRuleBase, table=True): + """Rules for automatically categorizing transactions.""" id: Optional[int] = Field(default=None, primary_key=True) + category: Optional[Category] = Relationship(back_populates="categorization_rules") + + +class TransactionBase(SQLModel): name: str = Field(default='', max_length=1000, description="Transaction name/payee") amount: Decimal = Field(max_digits=11, decimal_places=2, description="Transaction amount") date: datetime.date = Field(description="Date of transaction") - - account_id: Optional[int] = Field(default=None, foreign_key="account.id", description="Account ID") - account: Optional[Account] = Relationship(back_populates="transactions") - - category_id: Optional[int] = Field(default=None, foreign_key="category.id", description="Category ID") - category: Optional[Category] = Relationship(back_populates="transactions") - comment: str = Field(default='', max_length=4000, description="User comment") mean: Mean = Field(default=Mean.CARD, description="Payment mean") transaction_type: TransactionType = Field(default=TransactionType.EXPENSES, description="Type of transaction") reference: str = Field(default='', max_length=1000, unique=True, index=True, description="Unique reference ID") + account_id: Optional[int] = Field(default=None, foreign_key="account.id", description="Account ID") + category_id: Optional[int] = Field(default=None, foreign_key="category.id", description="Category ID") + + +class Transaction(TransactionBase, table=True): + """Represents a financial transaction.""" + id: Optional[int] = Field(default=None, primary_key=True) + + account: Optional[Account] = Relationship(back_populates="transactions") + category: Optional[Category] = Relationship(back_populates="transactions") diff --git a/budgetter_server/schemas/account.py b/budgetter_server/schemas/account.py deleted file mode 100644 index e6e48b8..0000000 --- a/budgetter_server/schemas/account.py +++ /dev/null @@ -1,38 +0,0 @@ -from datetime import date -from typing import Optional - -from pydantic import BaseModel, Field -from budgetter_server.models.sql_models import AccountType, Status - -class AccountBase(BaseModel): - """ - Base schema for Account data. - """ - name: Optional[str] = Field(default=None, description="Name of the account") - account_id: str = Field(description="Unique account identifier from the bank") - account_type: AccountType = Field(default=AccountType.CREDIT_CARD, description="Type of the account") - amount: float = Field(default=0.0, description="Current balance") - color: str = Field(default="", description="Color code for UI display") - last_update: date = Field(description="Date of last update") - status: Status = Field(default=Status.ACTIVE, description="Status of the account") - bank_id: Optional[int] = Field(default=None, description="ID of the associated bank") - -class AccountCreate(AccountBase): - """ - Schema for creating a new Account. - """ - pass - -class AccountRead(AccountBase): - """ - Schema for reading Account data (includes ID). - """ - id: int = Field(description="Unique database identifier") - -class AccountUpdate(BaseModel): - """ - Schema for updating an Account. - """ - name: Optional[str] = Field(default=None, description="New name for the account") - amount: Optional[float] = Field(default=None, description="New balance amount") - color: Optional[str] = Field(default=None, description="New color code") diff --git a/budgetter_server/schemas/transaction.py b/budgetter_server/schemas/transaction.py deleted file mode 100644 index 735a8c3..0000000 --- a/budgetter_server/schemas/transaction.py +++ /dev/null @@ -1,41 +0,0 @@ -import datetime -from decimal import Decimal -from typing import Optional - -from pydantic import BaseModel, Field -from budgetter_server.models.sql_models import Mean, TransactionType - -class TransactionBase(BaseModel): - """ - Base schema for Transaction data. - """ - name: str = Field(description="Name or payee of the transaction") - amount: Decimal = Field(description="Transaction amount") - date: datetime.date = Field(description="Date of the transaction") - comment: Optional[str] = Field(default="", description="Optional user comment") - mean: Mean = Field(default=Mean.CARD, description="Payment method used") - transaction_type: TransactionType = Field(default=TransactionType.EXPENSES, description="Type of transaction (Income/Expense)") - reference: str = Field(description="Unique reference ID for the transaction") - account_id: Optional[int] = Field(default=None, description="ID of the associated account") - category_id: Optional[int] = Field(default=None, description="ID of the associated category") - -class TransactionCreate(TransactionBase): - """ - Schema for creating a new Transaction. - """ - pass - -class TransactionRead(TransactionBase): - """ - Schema for reading Transaction data. - """ - id: int = Field(description="Unique database identifier") - -class TransactionUpdate(BaseModel): - """ - Schema for updating a Transaction. - """ - name: Optional[str] = Field(default=None, description="New name") - amount: Optional[Decimal] = Field(default=None, description="New amount") - comment: Optional[str] = Field(default=None, description="New comment") - category_id: Optional[int] = Field(default=None, description="New category ID") diff --git a/tests/api/v1/test_accounts.py b/tests/api/v1/test_accounts.py index 92bc9b9..1b07843 100644 --- a/tests/api/v1/test_accounts.py +++ b/tests/api/v1/test_accounts.py @@ -1,8 +1,7 @@ from fastapi.testclient import TestClient from sqlmodel import Session -from budgetter_server.schemas.account import AccountCreate -from budgetter_server.models.sql_models import AccountType, Status +from budgetter_server.models import AccountType, Status, AccountBase class TestAccounts: diff --git a/tests/api/v1/test_transactions.py b/tests/api/v1/test_transactions.py index 0d039af..67fc654 100644 --- a/tests/api/v1/test_transactions.py +++ b/tests/api/v1/test_transactions.py @@ -1,7 +1,7 @@ from fastapi.testclient import TestClient from sqlmodel import Session, select -from budgetter_server.models.sql_models import Transaction, Mean, TransactionType, AccountType, Status +from budgetter_server.models import Transaction, Mean, TransactionType, AccountType, Status class TestTransactions: From 66d086a39938f27fb1768ea73045b0c9d7b454db Mon Sep 17 00:00:00 2001 From: opierre Date: Fri, 12 Dec 2025 19:25:21 +0100 Subject: [PATCH 13/13] feat: add feature to import OFX file --- budgetter_server/api/v1/api.py | 3 +- .../api/v1/endpoints/import_ofx.py | 165 ++++++++++++++++++ pyproject.toml | 7 +- tests/api/v1/test_import.py | 45 +++++ tests/fixtures/sample.ofx | 63 +++++++ 5 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 budgetter_server/api/v1/endpoints/import_ofx.py create mode 100644 tests/api/v1/test_import.py create mode 100644 tests/fixtures/sample.ofx diff --git a/budgetter_server/api/v1/api.py b/budgetter_server/api/v1/api.py index 9eefa7c..371043f 100644 --- a/budgetter_server/api/v1/api.py +++ b/budgetter_server/api/v1/api.py @@ -1,7 +1,8 @@ from fastapi import APIRouter -from budgetter_server.api.v1.endpoints import accounts, transactions +from budgetter_server.api.v1.endpoints import accounts, transactions, import_ofx api_router = APIRouter() api_router.include_router(accounts.router, prefix="/accounts", tags=["accounts"]) api_router.include_router(transactions.router, prefix="/transactions", tags=["transactions"]) +api_router.include_router(import_ofx.router, prefix="/import", tags=["import"]) diff --git a/budgetter_server/api/v1/endpoints/import_ofx.py b/budgetter_server/api/v1/endpoints/import_ofx.py new file mode 100644 index 0000000..aad6105 --- /dev/null +++ b/budgetter_server/api/v1/endpoints/import_ofx.py @@ -0,0 +1,165 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, UploadFile, File, HTTPException +from sqlmodel import Session, select +from ofxtools.Parser import OFXTree +from ofxtools.models.bank.stmt import STMTRS +import io + +from budgetter_server.db.session import get_session +from budgetter_server.models import Bank, Account, AccountType, Transaction, Mean, TransactionType + +router = APIRouter() + + +@router.post("/ofx", response_model=dict[str, int]) +async def import_ofx( + file: Annotated[UploadFile, File()], + session: Session = Depends(get_session) +) -> dict[str, int]: + """ + Import transactions from an OFX file. + + Args: + file: The OFX file to upload. + session: Database session dependency. + + Returns: + dict: Summary including the count of imported transactions. + """ + if not file.filename.endswith(".ofx"): + raise HTTPException(status_code=400, detail="Invalid file type. Only .ofx files are supported.") + + content = await file.read() + + # Parse OFX + try: + parser = OFXTree() + parser.parse(io.BytesIO(content)) + ofx = parser.convert() + except Exception as exc: + raise HTTPException(status_code=400, detail=f"Failed to parse OFX file: {exc}") + + imported_count = 0 + + imported_count = 0 + + # Use the statements shortcut provided by ofxtools (handles BANKMSGSRSV1 and CREDITCARDMSGSRSV1) + if not hasattr(ofx, "statements"): + # Fallback or empty + return {"imported_count": 0} + + for stmt in ofx.statements: + # Determine account info + bank_id_str = "UNKNOWN" + acct_id_str = "UNKNOWN" + acct_type_enum = AccountType.CHECKING + + if hasattr(stmt, "bankacctfrom"): + acct_info = stmt.bankacctfrom + bank_id_str = str(acct_info.bankid) if hasattr(acct_info, "bankid") else "UNKNOWN" + acct_id_str = str(acct_info.acctid) + acct_type_node = acct_info.accttype if hasattr(acct_info, "accttype") else "CHECKING" + try: + acct_type_enum = AccountType(str(acct_type_node).upper()) + except ValueError: + acct_type_enum = AccountType.CHECKING + elif hasattr(stmt, "ccacctfrom"): + acct_info = stmt.ccacctfrom + bank_id_str = "UNKNOWN_CC" + acct_id_str = str(acct_info.acctid) + acct_type_enum = AccountType.CREDIT_CARD + else: + # Skip if no account info + continue + + # Find or Create Bank + bank = session.exec(select(Bank).where(Bank.swift == bank_id_str)).first() + if not bank: + bank = Bank(name=f"Bank {bank_id_str}", swift=bank_id_str) + session.add(bank) + session.commit() + session.refresh(bank) + + # Find or Create Account + account = session.exec(select(Account).where(Account.account_id == acct_id_str)).first() + if not account: + # Get balance + balance = 0.0 + if hasattr(stmt, "ledgerbal") and hasattr(stmt.ledgerbal, "balamt"): + balance = float(stmt.ledgerbal.balamt) + + account = Account( + name=f"Imported {acct_id_str}", + account_id=acct_id_str, + account_type=acct_type_enum, + bank_id=bank.id, + amount=balance + ) + session.add(account) + session.commit() + session.refresh(account) + + # Process Transactions + transactions = [] + if hasattr(stmt, "banktranlist"): + btl = stmt.banktranlist + try: + # Check for stmttrn (list or single) + txns = btl.stmttrn + if not isinstance(txns, list): + txns = [txns] + transactions = txns + except (AttributeError, KeyError): + # Fallback + if hasattr(btl, "__iter__"): + # Try iterating directly + transactions = list(btl) + + for txn in transactions: + fitid = str(txn.fitid) if hasattr(txn, "fitid") else None + if not fitid and hasattr(txn, "FITID"): fitid = str(txn.FITID) + + if not fitid: continue + + existing = session.exec(select(Transaction).where(Transaction.reference == fitid)).first() + if existing: + continue + + # Extract fields + t_type = txn.trntype if hasattr(txn, "trntype") else "OTHER" + amount = txn.trnamt if hasattr(txn, "trnamt") else 0 + + # Date handling + dt = None + if hasattr(txn, "dtposted"): + dt = txn.dtposted + elif hasattr(txn, "dtuser"): + dt = txn.dtuser + + if dt and hasattr(dt, "date"): + date_val = dt.date() + else: + import datetime + date_val = datetime.date.today() + + name = str(txn.name) if hasattr(txn, "name") else "" + memo = str(txn.memo) if hasattr(txn, "memo") else "" + final_name = name if name else memo + + new_txn = Transaction( + name=final_name, + amount=amount, + date=date_val, + mean=Mean.CARD, + transaction_type=TransactionType.EXPENSES if amount < 0 else TransactionType.INCOME, + reference=fitid, + account_id=account.id, + comment=memo + ) + session.add(new_txn) + imported_count += 1 + + session.commit() + + return {"imported_count": imported_count} diff --git a/pyproject.toml b/pyproject.toml index bc27ed2..2fc9e65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ description = "Finance manager server" readme = "README.md" requires-python = ">=3.13" keywords = ["finance", "budget", "management"] -license = { text = "GPL-3.0" } +license = "GPL-3.0-only" classifiers = [] dependencies = [ "psycopg[binary]>=3.2.13", @@ -21,6 +21,7 @@ dependencies = [ "uvicorn>=0.20.0", "sqlmodel>=0.0.14", "pydantic-settings>=2.0.0", + "python-multipart>=0.0.6", ] [project.optional-dependencies] @@ -47,6 +48,10 @@ exclude = ''' ) ''' +[tool.setuptools] +py-modules = [] + + [tool.pytest.ini_options] minversion = "7.0" addopts = "-ra -q --cov=budgetter_server --cov-report=term-missing --cov-report html:cov_html" diff --git a/tests/api/v1/test_import.py b/tests/api/v1/test_import.py new file mode 100644 index 0000000..ecf38d0 --- /dev/null +++ b/tests/api/v1/test_import.py @@ -0,0 +1,45 @@ +from pathlib import Path +from fastapi.testclient import TestClient +from sqlmodel import Session, select +from budgetter_server.models import Transaction, Bank, Account + +class TestImport: + """ + Test suite for OFX import. + """ + + def test_import_ofx(self, client: TestClient, session: Session) -> None: + """ + Test importing an OFX file. + + Verifies: + - Response 200 and count. + - Bank, Account, and Transactions are created in DB. + """ + file_path = Path(__file__).parent.parent.parent.joinpath("fixtures", "sample.ofx") + with open(file_path, "rb") as f: + response = client.post( + "/api/v1/import/ofx", + files={"file": ("sample.ofx", f, "application/x-ofx")} + ) + + assert response.status_code == 200 + data = response.json() + assert data["imported_count"] == 2 + + # Verify DB + bank = session.exec(select(Bank).where(Bank.swift == "TESTBANK")).first() + assert bank is not None + assert bank.name == "Bank TESTBANK" + + account = session.exec(select(Account).where(Account.account_id == "123456789")).first() + assert account is not None + assert account.amount == 5000.00 + assert account.bank_id == bank.id + + txns = session.exec(select(Transaction).where(Transaction.account_id == account.id)).all() + assert len(txns) == 2 + + debit = next(t for t in txns if t.reference == "REF101") + assert debit.amount == -50.00 + assert debit.name == "Grocery Store" diff --git a/tests/fixtures/sample.ofx b/tests/fixtures/sample.ofx new file mode 100644 index 0000000..9c6e220 --- /dev/null +++ b/tests/fixtures/sample.ofx @@ -0,0 +1,63 @@ +OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + +0 +INFO + +20231115120000 +ENG + + + + +1 + +0 +INFO + + +USD + +TESTBANK +123456789 +CHECKING + + +20231101120000 +20231115120000 + +DEBIT +20231110120000 +-50.00 +REF101 +Grocery Store +Weekly Food + + +CREDIT +20231112120000 +1200.00 +REF102 +Salary +November Pay + + + +5000.00 +20231115120000 + + + + +