Version : 2025.11.22

This commit is contained in:
2025-11-23 16:34:11 +01:00
commit b399e803b3
31 changed files with 5810 additions and 0 deletions

1
backend/.env.example Normal file
View File

@@ -0,0 +1 @@
DATABASE_URL=mysql+pymysql://user:password@host:port/database_name

49
backend/cli.py Normal file
View File

@@ -0,0 +1,49 @@
import typer
import uvicorn
from sqlmodel import SQLModel
from database import engine
from models import Association, Balance, Operation
from rich.console import Console
from rich.panel import Panel
app = typer.Typer()
console = Console()
@app.command()
def start(
host: str = "127.0.0.1",
port: int = 8000,
reload: bool = True
):
"""
Start the FastAPI server.
"""
console.print(Panel(f"Starting Abacus Backend on http://{host}:{port}", title="Abacus", style="bold green"))
uvicorn.run("main:app", host=host, port=port, reload=reload)
@app.command()
def setup_db():
"""
Create database tables.
"""
console.print("[bold yellow]Creating tables...[/bold yellow]")
SQLModel.metadata.create_all(engine)
console.print("[bold green]Tables created successfully.[/bold green]")
@app.command()
def reset_db():
"""
Drop and recreate database tables.
"""
confirm = typer.confirm("Are you sure you want to drop all tables?")
if not confirm:
console.print("[bold red]Aborted.[/bold red]")
raise typer.Abort()
console.print("[bold red]Dropping all tables...[/bold red]")
SQLModel.metadata.drop_all(engine)
console.print("[bold green]Tables dropped.[/bold green]")
setup_db()
if __name__ == "__main__":
app()

16
backend/database.py Normal file
View File

@@ -0,0 +1,16 @@
from sqlmodel import SQLModel, create_engine, Session
from dotenv import load_dotenv
import os
load_dotenv()
DATABASE_URL = os.getenv("DATABASE_URL")
if not DATABASE_URL:
raise ValueError("DATABASE_URL environment variable is not set")
engine = create_engine(DATABASE_URL, echo=True)
def get_session():
with Session(engine) as session:
yield session

250
backend/main.py Normal file
View File

@@ -0,0 +1,250 @@
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from sqlmodel import Session, select
from typing import List
from datetime import datetime
import os
from passlib.context import CryptContext
from database import get_session
from models import Association, Balance, Operation, OperationType, AssociationRead, BalanceRead
app = FastAPI()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
return pwd_context.hash(password)
origins = [
"http://localhost:5173",
"http://localhost:3000",
"http://localhost:9873",
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
from pydantic import BaseModel
class BalanceCreate(BaseModel):
name: str
amount: str
class SignupRequest(BaseModel):
name: str
password: str
balances: List[BalanceCreate]
class LoginRequest(BaseModel):
name: str
password: str
class OperationCreate(BaseModel):
name: str
description: str
group: str
amount: float
type: OperationType
date: datetime
balance_id: str
def association_to_read(association: Association) -> AssociationRead:
all_operations = []
balance_reads = []
for balance in association.balances:
ops = balance.operations
all_operations.extend(ops)
balance_reads.append(BalanceRead(
id=balance.id,
name=balance.name,
initialAmount=balance.initialAmount,
position=balance.position,
operations=ops
))
return AssociationRead(
id=association.id,
name=association.name,
balances=balance_reads,
operations=all_operations
)
@app.post("/api/signup", response_model=AssociationRead)
def signup(request: SignupRequest, session: Session = Depends(get_session)):
statement = select(Association).where(Association.name == request.name)
existing = session.exec(statement).first()
if existing:
raise HTTPException(status_code=400, detail="Association already exists")
hashed_password = get_password_hash(request.password)
association = Association(name=request.name, password=hashed_password)
session.add(association)
session.commit()
session.refresh(association)
for b in request.balances:
balance = Balance(
name=b.name,
initialAmount=float(b.amount),
association_id=association.id,
position=0
)
session.add(balance)
session.commit()
session.refresh(association)
return association_to_read(association)
@app.post("/api/login", response_model=AssociationRead)
def login(request: LoginRequest, session: Session = Depends(get_session)):
statement = select(Association).where(Association.name == request.name)
association = session.exec(statement).first()
if not association or not verify_password(request.password, association.password):
raise HTTPException(status_code=401, detail="Invalid credentials")
return association_to_read(association)
@app.get("/api/associations/{association_id}", response_model=AssociationRead)
def get_association(association_id: str, session: Session = Depends(get_session)):
from sqlalchemy.orm import selectinload
statement = select(Association).where(Association.id == association_id).options(
selectinload(Association.balances).selectinload(Balance.operations)
)
association = session.exec(statement).first()
if not association:
raise HTTPException(status_code=404, detail="Association not found")
return association_to_read(association)
@app.post("/api/operations")
def create_operation(op: OperationCreate, session: Session = Depends(get_session)):
balance = session.get(Balance, op.balance_id)
if not balance:
raise HTTPException(status_code=404, detail="Balance not found")
operation = Operation(
name=op.name,
description=op.description,
group=op.group,
amount=op.amount,
type=op.type,
date=op.date,
balance_id=op.balance_id
)
session.add(operation)
session.commit()
session.refresh(operation)
return operation
@app.delete("/api/operations/{operation_id}")
def delete_operation(operation_id: str, session: Session = Depends(get_session)):
operation = session.get(Operation, operation_id)
if not operation:
raise HTTPException(status_code=404, detail="Operation not found")
session.delete(operation)
session.commit()
session.commit()
return {"ok": True}
class OperationUpdate(BaseModel):
name: str
description: str
group: str
amount: float
type: OperationType
date: datetime
balance_id: str
@app.put("/api/operations/{operation_id}")
def update_operation(operation_id: str, op: OperationUpdate, session: Session = Depends(get_session)):
operation = session.get(Operation, operation_id)
if not operation:
raise HTTPException(status_code=404, detail="Operation not found")
operation.name = op.name
operation.description = op.description
operation.group = op.group
operation.amount = op.amount
operation.type = op.type
operation.date = op.date
operation.balance_id = op.balance_id
session.add(operation)
session.commit()
session.refresh(operation)
return operation
@app.post("/api/balances")
def create_balance(balance: BalanceCreate, association_id: str, session: Session = Depends(get_session)):
pass
class BalanceAddRequest(BaseModel):
name: str
initialAmount: float
association_id: str
@app.post("/api/balances_add")
def add_balance(request: BalanceAddRequest, session: Session = Depends(get_session)):
statement = select(Balance).where(Balance.association_id == request.association_id).order_by(Balance.position.desc())
last_balance = session.exec(statement).first()
new_position = (last_balance.position + 1) if last_balance else 0
balance = Balance(
name=request.name,
initialAmount=request.initialAmount,
association_id=request.association_id,
position=new_position
)
session.add(balance)
session.commit()
session.refresh(balance)
return balance
@app.delete("/api/balances/{balance_id}")
def delete_balance(balance_id: str, session: Session = Depends(get_session)):
balance = session.get(Balance, balance_id)
if not balance:
raise HTTPException(status_code=404, detail="Balance not found")
session.delete(balance)
session.commit()
return {"ok": True}
class BalanceUpdate(BaseModel):
name: str
initialAmount: float
position: int
@app.put("/api/balances/{balance_id}")
def update_balance(balance_id: str, data: BalanceUpdate, session: Session = Depends(get_session)):
balance = session.get(Balance, balance_id)
if not balance:
raise HTTPException(status_code=404, detail="Balance not found")
balance.name = data.name
balance.initialAmount = data.initialAmount
balance.position = data.position
session.add(balance)
session.commit()
session.refresh(balance)
return balance
static_dir = os.path.join(os.path.dirname(__file__), "static")
if os.path.exists(static_dir):
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
@app.get("/health")
def health_check():
return {"status": "ok"}

52
backend/models.py Normal file
View File

@@ -0,0 +1,52 @@
from typing import List, Optional
from sqlmodel import Field, Relationship, SQLModel
from enum import Enum
import uuid
from datetime import datetime
class OperationType(str, Enum):
INCOME = 'income'
EXPENSE = 'expense'
class Association(SQLModel, table=True):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
name: str
password: str
balances: List["Balance"] = Relationship(back_populates="association")
class Balance(SQLModel, table=True):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
name: str
initialAmount: float
association_id: Optional[str] = Field(default=None, foreign_key="association.id")
association: Optional[Association] = Relationship(back_populates="balances")
operations: List["Operation"] = Relationship(back_populates="balance")
position: int = Field(default=0)
class Operation(SQLModel, table=True):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
name: str
description: str
group: str
amount: float
type: OperationType
date: datetime
invoice: Optional[str] = None
balance_id: Optional[str] = Field(default=None, foreign_key="balance.id")
balance: Optional[Balance] = Relationship(back_populates="operations")
class BalanceRead(SQLModel):
id: str
name: str
initialAmount: float
position: int = 0
operations: List[Operation] = []
class AssociationRead(SQLModel):
id: str
name: str
balances: List[BalanceRead] = []
operations: List[Operation] = []

8
backend/requirements.txt Normal file
View File

@@ -0,0 +1,8 @@
fastapi
uvicorn
sqlmodel
pymysql
python-dotenv
typer
rich
passlib[bcrypt]