Version : 2025.11.22
This commit is contained in:
1
backend/.env.example
Normal file
1
backend/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
DATABASE_URL=mysql+pymysql://user:password@host:port/database_name
|
||||
49
backend/cli.py
Normal file
49
backend/cli.py
Normal 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
16
backend/database.py
Normal 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
250
backend/main.py
Normal 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
52
backend/models.py
Normal 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
8
backend/requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
sqlmodel
|
||||
pymysql
|
||||
python-dotenv
|
||||
typer
|
||||
rich
|
||||
passlib[bcrypt]
|
||||
Reference in New Issue
Block a user