diff --git a/budgetter_server/api/v1/api.py b/budgetter_server/api/v1/api.py new file mode 100644 index 0000000..371043f --- /dev/null +++ b/budgetter_server/api/v1/api.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +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/budgetter/__init__.py b/budgetter_server/api/v1/endpoints/__init__.py similarity index 100% rename from budgetter_server/budgetter/__init__.py rename to budgetter_server/api/v1/endpoints/__init__.py diff --git a/budgetter_server/api/v1/endpoints/accounts.py b/budgetter_server/api/v1/endpoints/accounts.py new file mode 100644 index 0000000..17b5154 --- /dev/null +++ b/budgetter_server/api/v1/endpoints/accounts.py @@ -0,0 +1,140 @@ +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 Account, AccountBase + +router = APIRouter() + + +@router.post("/", response_model=Account) +def create_account( + *, + session: Session = Depends(get_session), + account: AccountBase +) -> Account: + """ + Create a new account. + + Args: + session: Database session dependency. + account: Account creation data (AccountBase). + + 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[Account], response_model_exclude={"transactions", "bank"}) +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=Account, response_model_exclude={"transactions", "bank"}) +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 + + +@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/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/budgetter_server/api/v1/endpoints/transactions.py b/budgetter_server/api/v1/endpoints/transactions.py new file mode 100644 index 0000000..70225de --- /dev/null +++ b/budgetter_server/api/v1/endpoints/transactions.py @@ -0,0 +1,140 @@ +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, TransactionBase + +router = APIRouter() + + +@router.post("/", response_model=Transaction) +def create_transaction( + *, + session: Session = Depends(get_session), + transaction: TransactionBase +) -> Transaction: + """ + Create a new transaction. + + Args: + session: Database session dependency. + transaction: Transaction creation data (TransactionBase). + + 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[Transaction], response_model_exclude={"account", "category"}) +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 + + +@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/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/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/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/db/session.py b/budgetter_server/db/session.py new file mode 100644 index 0000000..e86a74e --- /dev/null +++ b/budgetter_server/db/session.py @@ -0,0 +1,28 @@ +from typing import Generator + +from sqlmodel import Session, create_engine, SQLModel + +from budgetter_server.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 diff --git a/budgetter_server/main.py b/budgetter_server/main.py new file mode 100644 index 0000000..2dc2298 --- /dev/null +++ b/budgetter_server/main.py @@ -0,0 +1,48 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from starlette.middleware.cors import CORSMiddleware + +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): + """ + 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"} 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/models/__init__.py b/budgetter_server/models/__init__.py new file mode 100644 index 0000000..9a284f5 --- /dev/null +++ b/budgetter_server/models/__init__.py @@ -0,0 +1,15 @@ +from .sql_models import ( + 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 new file mode 100644 index 0000000..fdf4c23 --- /dev/null +++ b/budgetter_server/models/sql_models.py @@ -0,0 +1,132 @@ +from enum import StrEnum +import datetime +from decimal import Decimal +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' + 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" + CHECKING = "CHECKING" + SAVINGS = "SAVINGS" + + +class Status(StrEnum): + """Enumeration for account status.""" + ACTIVE = 'ACTIVE' + CLOSED = 'CLOSED' + + +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") + + +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") + 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") + + +class Account(AccountBase, table=True): + """Represents a financial account.""" + id: Optional[int] = Field(default=None, primary_key=True) + + 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 Category(CategoryBase, table=True): + """Represents a transaction category.""" + id: Optional[int] = Field(default=None, primary_key=True) + + 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") + 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. + """ + 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: + if keyword in name_str or keyword in memo_str: + return True + try: + if re.search(keyword, name_str) or re.search(keyword, memo_str): + return True + except re.error: + continue + return False + + +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") + 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/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 diff --git a/pyproject.toml b/pyproject.toml index 9eb2649..2fc9e65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,21 +10,23 @@ 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 = [ - "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", + "python-multipart>=0.0.6", ] +[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" @@ -45,3 +47,14 @@ exclude = ''' ^skeletons/ ) ''' + +[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" +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..1b07843 --- /dev/null +++ b/tests/api/v1/test_accounts.py @@ -0,0 +1,65 @@ +from fastapi.testclient import TestClient +from sqlmodel import Session + +from budgetter_server.models import AccountType, Status, AccountBase + + +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_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/api/v1/test_transactions.py b/tests/api/v1/test_transactions.py new file mode 100644 index 0000000..67fc654 --- /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 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() 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 + + + + +