Обновление приложения

This commit is contained in:
2026-04-28 22:37:19 +03:00
parent 7d00cf062e
commit 52594b5dc6
99 changed files with 5736 additions and 1052 deletions

0
backend/app/__init__.py Normal file
View File

View File

View File

View File

@@ -0,0 +1,64 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user
from app.database import get_db
from app.models.account import Account, AccountType
from app.models.user import User
from app.schemas.account import AccountCreate, AccountUpdate, AccountOut, AccountTypeOut
router = APIRouter(prefix="/accounts", tags=["accounts"])
@router.get("/types", response_model=list[AccountTypeOut])
def list_types(db: Session = Depends(get_db), _=Depends(get_current_user)):
return db.query(AccountType).order_by(AccountType.name).all()
@router.get("", response_model=list[AccountOut])
def list_accounts(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
return db.query(Account).filter(Account.user_id == current_user.id).order_by(Account.name).all()
@router.get("/{account_id}", response_model=AccountOut)
def get_account(account_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
obj = db.get(Account, account_id)
if not obj or obj.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Account not found")
return obj
@router.post("", response_model=AccountOut, status_code=status.HTTP_201_CREATED)
def create_account(body: AccountCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
obj = Account(
**body.model_dump(),
user_id=current_user.id,
balance=body.initial_balance,
)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@router.patch("/{account_id}", response_model=AccountOut)
def update_account(
account_id: int, body: AccountUpdate,
db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
):
obj = db.get(Account, account_id)
if not obj or obj.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Account not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(obj, field, value)
db.commit()
db.refresh(obj)
return obj
@router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_account(account_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
obj = db.get(Account, account_id)
if not obj or obj.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Account not found")
db.delete(obj)
db.commit()

View File

@@ -0,0 +1,77 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from jose import JWTError
from sqlalchemy.orm import Session
from app.auth.security import verify_password, create_access_token, create_refresh_token, decode_token
from app.database import get_db
from app.models.user import User, Role
from app.schemas.auth import Token, LoginRequest
from app.schemas.user import UserCreate, UserOut
router = APIRouter(prefix="/auth", tags=["auth"])
def _authenticate(db: Session, username: str, password: str) -> User:
user = db.query(User).filter(
(User.username == username) | (User.email == username)
).first()
if not user or not verify_password(password, user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account is disabled")
return user
@router.post("/login", response_model=Token)
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
user = _authenticate(db, form.username, form.password)
return Token(
access_token=create_access_token(user.id, user.role.name),
refresh_token=create_refresh_token(user.id),
)
@router.post("/login/json", response_model=Token)
def login_json(body: LoginRequest, db: Session = Depends(get_db)):
user = _authenticate(db, body.username, body.password)
return Token(
access_token=create_access_token(user.id, user.role.name),
refresh_token=create_refresh_token(user.id),
)
@router.post("/refresh", response_model=Token)
def refresh(refresh_token: str, db: Session = Depends(get_db)):
try:
payload = decode_token(refresh_token)
if payload.get("type") != "refresh":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
user = db.get(User, int(payload["sub"]))
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
except JWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired refresh token")
return Token(
access_token=create_access_token(user.id, user.role.name),
refresh_token=create_refresh_token(user.id),
)
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def register(body: UserCreate, db: Session = Depends(get_db)):
from app.auth.security import hash_password
if db.query(User).filter(User.username == body.username).first():
raise HTTPException(status_code=400, detail="Username already taken")
if db.query(User).filter(User.email == body.email).first():
raise HTTPException(status_code=400, detail="Email already registered")
role = db.get(Role, body.role_id) or db.query(Role).filter(Role.name == "user").first()
user = User(
username=body.username,
email=body.email,
password_hash=hash_password(body.password),
role_id=role.id,
)
db.add(user)
db.commit()
db.refresh(user)
return user

View File

@@ -0,0 +1,80 @@
from datetime import date
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user
from app.database import get_db
from app.models.budget import Budget
from app.models.operation import Operation
from app.models.user import User
from app.schemas.budget import BudgetCreate, BudgetUpdate, BudgetOut
router = APIRouter(prefix="/budgets", tags=["budgets"])
def _compute_spent(db: Session, budget: Budget, current_user: User) -> Decimal:
period_start = budget.start_date
period_end = budget.end_date or date.today()
total = db.query(func.sum(Operation.amount)).filter(
Operation.user_id == current_user.id,
Operation.category_id == budget.category_id,
func.date(Operation.date) >= period_start,
func.date(Operation.date) <= period_end,
Operation.is_planned == False,
).scalar() or Decimal("0")
return total
@router.get("", response_model=list[BudgetOut])
def list_budgets(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
budgets = db.query(Budget).filter(Budget.user_id == current_user.id).all()
result = []
for b in budgets:
out = BudgetOut.model_validate(b)
out.spent = _compute_spent(db, b, current_user)
result.append(out)
return result
@router.get("/{budget_id}", response_model=BudgetOut)
def get_budget(budget_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
obj = db.get(Budget, budget_id)
if not obj or obj.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Budget not found")
out = BudgetOut.model_validate(obj)
out.spent = _compute_spent(db, obj, current_user)
return out
@router.post("", response_model=BudgetOut, status_code=status.HTTP_201_CREATED)
def create_budget(body: BudgetCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
obj = Budget(**body.model_dump(), user_id=current_user.id)
db.add(obj)
db.commit()
db.refresh(obj)
return BudgetOut.model_validate(obj)
@router.patch("/{budget_id}", response_model=BudgetOut)
def update_budget(
budget_id: int, body: BudgetUpdate,
db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
):
obj = db.get(Budget, budget_id)
if not obj or obj.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Budget not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(obj, field, value)
db.commit()
db.refresh(obj)
return BudgetOut.model_validate(obj)
@router.delete("/{budget_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_budget(budget_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
obj = db.get(Budget, budget_id)
if not obj or obj.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Budget not found")
db.delete(obj)
db.commit()

View File

@@ -0,0 +1,73 @@
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user
from app.database import get_db
from app.models.calendar import CalendarEvent
from app.models.user import User
from app.schemas.calendar import CalendarEventCreate, CalendarEventUpdate, CalendarEventOut
router = APIRouter(prefix="/calendar", tags=["calendar"])
@router.get("", response_model=list[CalendarEventOut])
def list_events(
date_from: date | None = None,
date_to: date | None = None,
is_completed: bool | None = None,
category_id: int | None = None,
limit: int = Query(100, le=500),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = db.query(CalendarEvent).filter(CalendarEvent.user_id == current_user.id)
if date_from:
q = q.filter(CalendarEvent.event_date >= date_from)
if date_to:
q = q.filter(CalendarEvent.event_date <= date_to)
if is_completed is not None:
q = q.filter(CalendarEvent.is_completed == is_completed)
if category_id:
q = q.filter(CalendarEvent.category_id == category_id)
return q.order_by(CalendarEvent.event_date, CalendarEvent.event_time).limit(limit).all()
@router.get("/{event_id}", response_model=CalendarEventOut)
def get_event(event_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
obj = db.get(CalendarEvent, event_id)
if not obj or obj.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Event not found")
return obj
@router.post("", response_model=CalendarEventOut, status_code=status.HTTP_201_CREATED)
def create_event(body: CalendarEventCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
obj = CalendarEvent(**body.model_dump(), user_id=current_user.id)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@router.patch("/{event_id}", response_model=CalendarEventOut)
def update_event(
event_id: int, body: CalendarEventUpdate,
db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
):
obj = db.get(CalendarEvent, event_id)
if not obj or obj.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Event not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(obj, field, value)
db.commit()
db.refresh(obj)
return obj
@router.delete("/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_event(event_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
obj = db.get(CalendarEvent, event_id)
if not obj or obj.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Event not found")
db.delete(obj)
db.commit()

View File

@@ -0,0 +1,49 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user
from app.database import get_db
from app.models.category import Category
from app.models.user import User
from app.schemas.category import CategoryCreate, CategoryUpdate, CategoryOut
router = APIRouter(prefix="/categories", tags=["categories"])
@router.get("", response_model=list[CategoryOut])
def list_categories(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
return db.query(Category).filter(
(Category.user_id == current_user.id) | (Category.user_id == None)
).order_by(Category.type, Category.name).all()
@router.post("", response_model=CategoryOut, status_code=status.HTTP_201_CREATED)
def create_category(body: CategoryCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
obj = Category(**body.model_dump(), user_id=current_user.id)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@router.patch("/{category_id}", response_model=CategoryOut)
def update_category(
category_id: int, body: CategoryUpdate,
db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
):
obj = db.get(Category, category_id)
if not obj or (obj.user_id and obj.user_id != current_user.id):
raise HTTPException(status_code=404, detail="Category not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(obj, field, value)
db.commit()
db.refresh(obj)
return obj
@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_category(category_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
obj = db.get(Category, category_id)
if not obj or (obj.user_id and obj.user_id != current_user.id):
raise HTTPException(status_code=404, detail="Category not found")
db.delete(obj)
db.commit()

View File

@@ -0,0 +1,55 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user, require_role
from app.database import get_db
from app.models.currency import Currency
from app.schemas.currency import CurrencyCreate, CurrencyUpdate, CurrencyOut
router = APIRouter(prefix="/currencies", tags=["currencies"])
require_accountant = require_role("accountant")
@router.get("", response_model=list[CurrencyOut])
def list_currencies(db: Session = Depends(get_db), _=Depends(get_current_user)):
return db.query(Currency).order_by(Currency.code).all()
@router.get("/{currency_id}", response_model=CurrencyOut)
def get_currency(currency_id: int, db: Session = Depends(get_db), _=Depends(get_current_user)):
obj = db.get(Currency, currency_id)
if not obj:
raise HTTPException(status_code=404, detail="Currency not found")
return obj
@router.post("", response_model=CurrencyOut, status_code=status.HTTP_201_CREATED)
def create_currency(body: CurrencyCreate, db: Session = Depends(get_db), _=Depends(require_accountant)):
if db.query(Currency).filter(Currency.code == body.code.upper()).first():
raise HTTPException(status_code=400, detail="Currency code already exists")
obj = Currency(**body.model_dump(), code=body.code.upper())
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@router.patch("/{currency_id}", response_model=CurrencyOut)
def update_currency(currency_id: int, body: CurrencyUpdate, db: Session = Depends(get_db), _=Depends(require_accountant)):
obj = db.get(Currency, currency_id)
if not obj:
raise HTTPException(status_code=404, detail="Currency not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(obj, field, value)
db.commit()
db.refresh(obj)
return obj
@router.delete("/{currency_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_currency(currency_id: int, db: Session = Depends(get_db), _=Depends(require_accountant)):
obj = db.get(Currency, currency_id)
if not obj:
raise HTTPException(status_code=404, detail="Currency not found")
db.delete(obj)
db.commit()

View File

@@ -0,0 +1,61 @@
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user, require_role
from app.database import get_db
from app.models.currency import ExchangeRate
from app.schemas.currency import ExchangeRateCreate, ExchangeRateOut
router = APIRouter(prefix="/exchange-rates", tags=["exchange-rates"])
require_accountant = require_role("accountant")
@router.get("", response_model=list[ExchangeRateOut])
def list_rates(
currency_from_id: int | None = None,
currency_to_id: int | None = None,
rate_date: date | None = None,
limit: int = Query(50, le=500),
db: Session = Depends(get_db),
_=Depends(get_current_user),
):
q = db.query(ExchangeRate)
if currency_from_id:
q = q.filter(ExchangeRate.currency_from_id == currency_from_id)
if currency_to_id:
q = q.filter(ExchangeRate.currency_to_id == currency_to_id)
if rate_date:
q = q.filter(ExchangeRate.rate_date == rate_date)
return q.order_by(ExchangeRate.rate_date.desc()).limit(limit).all()
@router.post("", response_model=ExchangeRateOut, status_code=status.HTTP_201_CREATED)
def create_rate(body: ExchangeRateCreate, db: Session = Depends(get_db), _=Depends(require_accountant)):
if body.currency_from_id == body.currency_to_id:
raise HTTPException(status_code=400, detail="Cannot set rate between the same currency")
existing = db.query(ExchangeRate).filter(
ExchangeRate.currency_from_id == body.currency_from_id,
ExchangeRate.currency_to_id == body.currency_to_id,
ExchangeRate.rate_date == body.rate_date,
).first()
if existing:
for field, value in body.model_dump(exclude_none=True).items():
setattr(existing, field, value)
db.commit()
db.refresh(existing)
return existing
obj = ExchangeRate(**body.model_dump())
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@router.delete("/{rate_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_rate(rate_id: int, db: Session = Depends(get_db), _=Depends(require_accountant)):
obj = db.get(ExchangeRate, rate_id)
if not obj:
raise HTTPException(status_code=404, detail="Exchange rate not found")
db.delete(obj)
db.commit()

View File

@@ -0,0 +1,49 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user
from app.database import get_db
from app.models.location import Location
from app.models.user import User
from app.schemas.location import LocationCreate, LocationUpdate, LocationOut
router = APIRouter(prefix="/locations", tags=["locations"])
@router.get("", response_model=list[LocationOut])
def list_locations(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
return db.query(Location).filter(
(Location.user_id == current_user.id) | (Location.user_id == None)
).order_by(Location.name).all()
@router.post("", response_model=LocationOut, status_code=status.HTTP_201_CREATED)
def create_location(body: LocationCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
obj = Location(**body.model_dump(), user_id=current_user.id)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@router.patch("/{location_id}", response_model=LocationOut)
def update_location(
location_id: int, body: LocationUpdate,
db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
):
obj = db.get(Location, location_id)
if not obj or (obj.user_id and obj.user_id != current_user.id and current_user.role.name != "admin"):
raise HTTPException(status_code=404, detail="Location not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(obj, field, value)
db.commit()
db.refresh(obj)
return obj
@router.delete("/{location_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_location(location_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
obj = db.get(Location, location_id)
if not obj or (obj.user_id and obj.user_id != current_user.id and current_user.role.name != "admin"):
raise HTTPException(status_code=404, detail="Location not found")
db.delete(obj)
db.commit()

View File

@@ -0,0 +1,163 @@
from datetime import datetime
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user
from app.database import get_db
from app.models.account import Account
from app.models.operation import Operation, OperationType, OperationRelation
from app.models.user import User
from app.schemas.operation import (
OperationCreate, OperationUpdate, OperationOut,
OperationTypeOut, TransferCreate,
OperationRelationOut,
)
router = APIRouter(prefix="/operations", tags=["operations"])
def _assert_account_ownership(db: Session, account_id: int, user_id: int) -> Account:
account = db.get(Account, account_id)
if not account or account.user_id != user_id:
raise HTTPException(status_code=404, detail="Account not found")
return account
def _apply_balance(account: Account, op_type_name: str, amount: Decimal, sign: int = 1):
if op_type_name == "income":
account.balance += amount * sign
elif op_type_name == "expense":
account.balance -= amount * sign
@router.get("/types", response_model=list[OperationTypeOut])
def list_types(db: Session = Depends(get_db), _=Depends(get_current_user)):
return db.query(OperationType).all()
@router.get("", response_model=list[OperationOut])
def list_operations(
account_id: int | None = None,
category_id: int | None = None,
operation_type_id: int | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
is_planned: bool | None = None,
limit: int = Query(100, le=1000),
offset: int = 0,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = db.query(Operation).filter(Operation.user_id == current_user.id)
if account_id:
q = q.filter(Operation.account_id == account_id)
if category_id:
q = q.filter(Operation.category_id == category_id)
if operation_type_id:
q = q.filter(Operation.operation_type_id == operation_type_id)
if date_from:
q = q.filter(Operation.date >= date_from)
if date_to:
q = q.filter(Operation.date <= date_to)
if is_planned is not None:
q = q.filter(Operation.is_planned == is_planned)
return q.order_by(Operation.date.desc()).offset(offset).limit(limit).all()
@router.get("/{operation_id}", response_model=OperationOut)
def get_operation(operation_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
obj = db.get(Operation, operation_id)
if not obj or obj.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Operation not found")
return obj
@router.post("", response_model=OperationOut, status_code=status.HTTP_201_CREATED)
def create_operation(
body: OperationCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
):
account = _assert_account_ownership(db, body.account_id, current_user.id)
op_type = db.get(OperationType, body.operation_type_id)
if not op_type:
raise HTTPException(status_code=400, detail="Invalid operation type")
obj = Operation(**body.model_dump(), user_id=current_user.id)
db.add(obj)
_apply_balance(account, op_type.name, body.amount)
db.commit()
db.refresh(obj)
return obj
@router.post("/transfer", response_model=list[OperationOut], status_code=status.HTTP_201_CREATED)
def create_transfer(
body: TransferCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
):
from_account = _assert_account_ownership(db, body.from_account_id, current_user.id)
to_account = _assert_account_ownership(db, body.to_account_id, current_user.id)
transfer_type = db.query(OperationType).filter(OperationType.name == "transfer").first()
if not transfer_type:
raise HTTPException(status_code=500, detail="Transfer operation type not configured")
op_out = Operation(
user_id=current_user.id, account_id=body.from_account_id,
operation_type_id=transfer_type.id, amount=body.amount,
currency_id=body.currency_id, exchange_rate=body.exchange_rate,
amount_converted=body.amount_converted, date=body.date,
description=body.description, location_id=body.location_id,
)
amount_in = body.amount_converted or body.amount
op_in = Operation(
user_id=current_user.id, account_id=body.to_account_id,
operation_type_id=transfer_type.id, amount=amount_in,
currency_id=to_account.currency_id, date=body.date,
description=body.description, location_id=body.location_id,
)
from_account.balance -= body.amount
to_account.balance += amount_in
db.add_all([op_out, op_in])
db.flush()
relation = OperationRelation(
operation_from_id=op_out.id, operation_to_id=op_in.id, relation_type="transfer"
)
db.add(relation)
db.commit()
db.refresh(op_out)
db.refresh(op_in)
return [op_out, op_in]
@router.patch("/{operation_id}", response_model=OperationOut)
def update_operation(
operation_id: int, body: OperationUpdate,
db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
):
obj = db.get(Operation, operation_id)
if not obj or obj.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Operation not found")
if body.amount is not None and body.amount != obj.amount:
account = db.get(Account, obj.account_id)
op_type = db.get(OperationType, obj.operation_type_id)
_apply_balance(account, op_type.name, obj.amount, sign=-1)
_apply_balance(account, op_type.name, body.amount, sign=1)
for field, value in body.model_dump(exclude_none=True).items():
setattr(obj, field, value)
db.commit()
db.refresh(obj)
return obj
@router.delete("/{operation_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_operation(
operation_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
):
obj = db.get(Operation, operation_id)
if not obj or obj.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Operation not found")
account = db.get(Account, obj.account_id)
op_type = db.get(OperationType, obj.operation_type_id)
_apply_balance(account, op_type.name, obj.amount, sign=-1)
db.delete(obj)
db.commit()

View File

@@ -0,0 +1,51 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user
from app.database import get_db
from app.models.operation import Operation, OperationRelation
from app.models.user import User
from app.schemas.operation import OperationRelationCreate, OperationRelationOut
router = APIRouter(prefix="/operation-relations", tags=["operation-relations"])
@router.get("/{operation_id}", response_model=list[OperationRelationOut])
def list_relations(
operation_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
):
op = db.get(Operation, operation_id)
if not op or op.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Operation not found")
return db.query(OperationRelation).filter(
(OperationRelation.operation_from_id == operation_id) |
(OperationRelation.operation_to_id == operation_id)
).all()
@router.post("", response_model=OperationRelationOut, status_code=status.HTTP_201_CREATED)
def create_relation(
body: OperationRelationCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
):
for op_id in (body.operation_from_id, body.operation_to_id):
op = db.get(Operation, op_id)
if not op or op.user_id != current_user.id:
raise HTTPException(status_code=404, detail=f"Operation {op_id} not found")
obj = OperationRelation(**body.model_dump())
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@router.delete("/{relation_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_relation(
relation_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
):
obj = db.get(OperationRelation, relation_id)
if not obj:
raise HTTPException(status_code=404, detail="Relation not found")
op = db.get(Operation, obj.operation_from_id)
if not op or op.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Access denied")
db.delete(obj)
db.commit()

View File

@@ -0,0 +1,15 @@
from fastapi import APIRouter
from app.api.v1 import auth, users, currencies, exchange_rates, locations, accounts, categories, budgets, operations, relations, calendar
router = APIRouter()
router.include_router(auth.router)
router.include_router(users.router)
router.include_router(currencies.router)
router.include_router(exchange_rates.router)
router.include_router(locations.router)
router.include_router(accounts.router)
router.include_router(categories.router)
router.include_router(budgets.router)
router.include_router(operations.router)
router.include_router(relations.router)
router.include_router(calendar.router)

View File

@@ -0,0 +1,52 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user, require_role
from app.auth.security import hash_password
from app.database import get_db
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate, UserOut, UserWithRole
router = APIRouter(prefix="/users", tags=["users"])
require_admin = require_role("admin")
@router.get("/me", response_model=UserWithRole)
def get_me(current_user: User = Depends(get_current_user)):
return current_user
@router.get("", response_model=list[UserWithRole])
def list_users(db: Session = Depends(get_db), _=Depends(require_admin)):
return db.query(User).all()
@router.get("/{user_id}", response_model=UserWithRole)
def get_user(user_id: int, db: Session = Depends(get_db), _=Depends(require_admin)):
user = db.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
@router.patch("/{user_id}", response_model=UserOut)
def update_user(user_id: int, body: UserUpdate, db: Session = Depends(get_db), _=Depends(require_admin)):
user = db.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(user, field, value)
db.commit()
db.refresh(user)
return user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(user_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_admin)):
if user_id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot delete yourself")
user = db.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
db.delete(user)
db.commit()

View File

View File

@@ -0,0 +1,54 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError
from sqlalchemy.orm import Session
from app.auth.security import decode_token
from app.database import get_db
from app.models.user import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
ROLES = {"admin": 4, "accountant": 3, "user": 2, "viewer": 1}
def _get_current_user(token: str, db: Session) -> User:
credentials_exc = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_token(token)
if payload.get("type") != "access":
raise credentials_exc
user_id = int(payload["sub"])
except (JWTError, KeyError, ValueError):
raise credentials_exc
user = db.get(User, user_id)
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User inactive or not found")
return user
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
return _get_current_user(token, db)
def require_role(minimum_role: str):
"""Dependency factory — blocks users whose role rank is below minimum_role."""
min_rank = ROLES[minimum_role]
def _check(current_user: User = Depends(get_current_user)) -> User:
user_rank = ROLES.get(current_user.role.name, 0)
if user_rank < min_rank:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
return current_user
return _check
require_viewer = Depends(require_role("viewer"))
require_user = Depends(require_role("user"))
require_accountant = Depends(require_role("accountant"))
require_admin = Depends(require_role("admin"))

View File

@@ -0,0 +1,41 @@
from datetime import datetime, timedelta, timezone
from typing import Any
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.config import get_settings
settings = get_settings()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def _create_token(data: dict[str, Any], expires_delta: timedelta) -> str:
payload = data.copy()
payload["exp"] = datetime.now(timezone.utc) + expires_delta
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def create_access_token(user_id: int, role: str) -> str:
return _create_token(
{"sub": str(user_id), "role": role, "type": "access"},
timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
)
def create_refresh_token(user_id: int) -> str:
return _create_token(
{"sub": str(user_id), "type": "refresh"},
timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
)
def decode_token(token: str) -> dict[str, Any]:
return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])

25
backend/app/config.py Normal file
View File

@@ -0,0 +1,25 @@
from functools import lru_cache
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
DATABASE_URL: str
SECRET_KEY: str
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
DEBUG: bool = False
API_V1_PREFIX: str = "/api/v1"
ALLOWED_ORIGINS: str = "http://localhost:3000"
@property
def origins_list(self) -> list[str]:
return [o.strip() for o in self.ALLOWED_ORIGINS.split(",")]
class Config:
env_file = ".env"
@lru_cache()
def get_settings() -> Settings:
return Settings()

33
backend/app/database.py Normal file
View File

@@ -0,0 +1,33 @@
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from app.config import get_settings
settings = get_settings()
_connect_args: dict = {}
if settings.DATABASE_URL.startswith("postgresql"):
_connect_args = {"sslmode": "prefer"}
engine = create_engine(
settings.DATABASE_URL,
connect_args=_connect_args,
pool_size=10,
max_overflow=20,
pool_pre_ping=True,
pool_recycle=3600,
)
class Base(DeclarativeBase):
pass
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

29
backend/app/main.py Normal file
View File

@@ -0,0 +1,29 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import get_settings
from app.api.v1.router import router as api_router
settings = get_settings()
app = FastAPI(
title="Finance App API",
description="Personal finance management inspired by 1C:Money",
version="1.0.0",
docs_url="/docs" if settings.DEBUG else None,
redoc_url="/redoc" if settings.DEBUG else None,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router, prefix=settings.API_V1_PREFIX)
@app.get("/health")
def health():
return {"status": "ok"}

View File

@@ -0,0 +1,19 @@
from app.models.user import Role, User
from app.models.currency import Currency, ExchangeRate
from app.models.location import Location
from app.models.account import AccountType, Account
from app.models.category import Category
from app.models.budget import Budget
from app.models.operation import OperationType, Operation, OperationRelation
from app.models.calendar import CalendarEvent
__all__ = [
"Role", "User",
"Currency", "ExchangeRate",
"Location",
"AccountType", "Account",
"Category",
"Budget",
"OperationType", "Operation", "OperationRelation",
"CalendarEvent",
]

View File

@@ -0,0 +1,42 @@
from datetime import datetime
from decimal import Decimal
from sqlalchemy import String, Boolean, Text, Numeric, ForeignKey, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class AccountType(Base):
__tablename__ = "account_types"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
icon: Mapped[str | None] = mapped_column(String(50))
description: Mapped[str | None] = mapped_column(Text)
accounts: Mapped[list["Account"]] = relationship("Account", back_populates="account_type")
class Account(Base):
__tablename__ = "accounts"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
account_type_id: Mapped[int] = mapped_column(ForeignKey("account_types.id"), nullable=False)
currency_id: Mapped[int] = mapped_column(ForeignKey("currencies.id"), nullable=False)
balance: Mapped[Decimal] = mapped_column(Numeric(20, 2), default=0, nullable=False)
initial_balance: Mapped[Decimal] = mapped_column(Numeric(20, 2), default=0, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
credit_limit: Mapped[Decimal | None] = mapped_column(Numeric(20, 2))
description: Mapped[str | None] = mapped_column(Text)
icon: Mapped[str | None] = mapped_column(String(50))
color: Mapped[str | None] = mapped_column(String(7)) # hex #RRGGBB
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
user: Mapped["User"] = relationship("User", back_populates="accounts")
account_type: Mapped["AccountType"] = relationship("AccountType", back_populates="accounts")
currency: Mapped["Currency"] = relationship("Currency", back_populates="accounts")
operations: Mapped[list] = relationship("Operation", back_populates="account")

View File

@@ -0,0 +1,25 @@
from datetime import datetime, date
from decimal import Decimal
from sqlalchemy import String, Boolean, Numeric, ForeignKey, Date, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Budget(Base):
__tablename__ = "budgets"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
category_id: Mapped[int] = mapped_column(ForeignKey("categories.id"), nullable=False)
name: Mapped[str] = mapped_column(String(200), nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
currency_id: Mapped[int] = mapped_column(ForeignKey("currencies.id"), nullable=False)
period_type: Mapped[str] = mapped_column(String(20), nullable=False) # monthly | weekly | yearly | custom
start_date: Mapped[date] = mapped_column(Date, nullable=False)
end_date: Mapped[date | None] = mapped_column(Date)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped["User"] = relationship("User", back_populates="budgets")
category: Mapped["Category"] = relationship("Category", back_populates="budgets")
currency: Mapped["Currency"] = relationship("Currency", back_populates="budgets")

View File

@@ -0,0 +1,34 @@
from datetime import datetime, date, time
from decimal import Decimal
from sqlalchemy import String, Boolean, Text, Numeric, ForeignKey, Date, Time, DateTime, Integer, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class CalendarEvent(Base):
__tablename__ = "calendar_events"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
event_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
event_time: Mapped[time | None] = mapped_column(Time)
is_recurring: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
recurrence_pattern: Mapped[str | None] = mapped_column(String(50)) # daily | weekly | monthly | yearly
recurrence_end_date: Mapped[date | None] = mapped_column(Date)
related_operation_id: Mapped[int | None] = mapped_column(ForeignKey("operations.id"), nullable=True)
category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"), nullable=True)
amount: Mapped[Decimal | None] = mapped_column(Numeric(20, 2))
currency_id: Mapped[int | None] = mapped_column(ForeignKey("currencies.id"), nullable=True)
reminder_days: Mapped[int | None] = mapped_column(Integer)
is_completed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
user: Mapped["User"] = relationship("User", back_populates="calendar_events")
related_operation: Mapped["Operation | None"] = relationship("Operation", back_populates="calendar_events")
category: Mapped["Category | None"] = relationship("Category", back_populates="calendar_events")
currency: Mapped["Currency | None"] = relationship("Currency", back_populates="calendar_events")

View File

@@ -0,0 +1,26 @@
from datetime import datetime
from sqlalchemy import String, ForeignKey, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Category(Base):
__tablename__ = "categories"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
type: Mapped[str] = mapped_column(String(20), nullable=False) # income | expense | transfer
parent_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"), nullable=True)
icon: Mapped[str | None] = mapped_column(String(50))
color: Mapped[str | None] = mapped_column(String(7))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped["User | None"] = relationship("User", back_populates="categories")
parent: Mapped["Category | None"] = relationship(
"Category", remote_side="Category.id", back_populates="children"
)
children: Mapped[list["Category"]] = relationship("Category", back_populates="parent")
operations: Mapped[list] = relationship("Operation", back_populates="category")
budgets: Mapped[list] = relationship("Budget", back_populates="category")
calendar_events: Mapped[list] = relationship("CalendarEvent", back_populates="category")

View File

@@ -0,0 +1,49 @@
from datetime import datetime, date
from decimal import Decimal
from sqlalchemy import String, Boolean, Numeric, ForeignKey, Date, DateTime, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Currency(Base):
__tablename__ = "currencies"
id: Mapped[int] = mapped_column(primary_key=True)
code: Mapped[str] = mapped_column(String(3), unique=True, nullable=False) # ISO 4217
name: Mapped[str] = mapped_column(String(100), nullable=False)
symbol: Mapped[str | None] = mapped_column(String(10))
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
exchange_rates_from: Mapped[list["ExchangeRate"]] = relationship(
"ExchangeRate", foreign_keys="ExchangeRate.currency_from_id", back_populates="currency_from"
)
exchange_rates_to: Mapped[list["ExchangeRate"]] = relationship(
"ExchangeRate", foreign_keys="ExchangeRate.currency_to_id", back_populates="currency_to"
)
accounts: Mapped[list] = relationship("Account", back_populates="currency")
operations: Mapped[list] = relationship("Operation", back_populates="currency")
budgets: Mapped[list] = relationship("Budget", back_populates="currency")
calendar_events: Mapped[list] = relationship("CalendarEvent", back_populates="currency")
class ExchangeRate(Base):
__tablename__ = "exchange_rates"
__table_args__ = (
UniqueConstraint("currency_from_id", "currency_to_id", "rate_date", name="uq_exchange_rate_day"),
)
id: Mapped[int] = mapped_column(primary_key=True)
currency_from_id: Mapped[int] = mapped_column(ForeignKey("currencies.id"), nullable=False)
currency_to_id: Mapped[int] = mapped_column(ForeignKey("currencies.id"), nullable=False)
rate: Mapped[Decimal] = mapped_column(Numeric(20, 6), nullable=False)
rate_date: Mapped[date] = mapped_column(Date, nullable=False)
source: Mapped[str | None] = mapped_column(String(100))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
currency_from: Mapped["Currency"] = relationship(
"Currency", foreign_keys=[currency_from_id], back_populates="exchange_rates_from"
)
currency_to: Mapped["Currency"] = relationship(
"Currency", foreign_keys=[currency_to_id], back_populates="exchange_rates_to"
)

View File

@@ -0,0 +1,26 @@
from datetime import datetime
from decimal import Decimal
from sqlalchemy import String, Text, Numeric, ForeignKey, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Location(Base):
__tablename__ = "locations"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) # None = system location
name: Mapped[str] = mapped_column(String(200), nullable=False)
type: Mapped[str | None] = mapped_column(String(50)) # store, bank, atm, online, other
address: Mapped[str | None] = mapped_column(Text)
city: Mapped[str | None] = mapped_column(String(100))
country: Mapped[str | None] = mapped_column(String(100))
latitude: Mapped[Decimal | None] = mapped_column(Numeric(9, 6))
longitude: Mapped[Decimal | None] = mapped_column(Numeric(9, 6))
parent_id: Mapped[int | None] = mapped_column(ForeignKey("locations.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped["User | None"] = relationship("User", back_populates="locations")
parent: Mapped["Location | None"] = relationship("Location", remote_side="Location.id", back_populates="children")
children: Mapped[list["Location"]] = relationship("Location", back_populates="parent")
operations: Mapped[list] = relationship("Operation", back_populates="location")

View File

@@ -0,0 +1,70 @@
from datetime import datetime
from decimal import Decimal
from sqlalchemy import String, Boolean, Text, Numeric, ForeignKey, DateTime, func, ARRAY
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class OperationType(Base):
__tablename__ = "operation_types"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) # income | expense | transfer
description: Mapped[str | None] = mapped_column(Text)
operations: Mapped[list["Operation"]] = relationship("Operation", back_populates="operation_type")
class Operation(Base):
__tablename__ = "operations"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
account_id: Mapped[int] = mapped_column(ForeignKey("accounts.id"), nullable=False, index=True)
category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"), nullable=True)
operation_type_id: Mapped[int] = mapped_column(ForeignKey("operation_types.id"), nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
currency_id: Mapped[int] = mapped_column(ForeignKey("currencies.id"), nullable=False)
amount_converted: Mapped[Decimal | None] = mapped_column(Numeric(20, 2)) # in account currency
exchange_rate: Mapped[Decimal | None] = mapped_column(Numeric(20, 6))
date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
description: Mapped[str | None] = mapped_column(Text)
location_id: Mapped[int | None] = mapped_column(ForeignKey("locations.id"), nullable=True)
is_planned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
user: Mapped["User"] = relationship("User", back_populates="operations")
account: Mapped["Account"] = relationship("Account", back_populates="operations")
category: Mapped["Category | None"] = relationship("Category", back_populates="operations")
operation_type: Mapped["OperationType"] = relationship("OperationType", back_populates="operations")
currency: Mapped["Currency"] = relationship("Currency", back_populates="operations")
location: Mapped["Location | None"] = relationship("Location", back_populates="operations")
relations_from: Mapped[list["OperationRelation"]] = relationship(
"OperationRelation", foreign_keys="OperationRelation.operation_from_id", back_populates="operation_from"
)
relations_to: Mapped[list["OperationRelation"]] = relationship(
"OperationRelation", foreign_keys="OperationRelation.operation_to_id", back_populates="operation_to"
)
calendar_events: Mapped[list] = relationship("CalendarEvent", back_populates="related_operation")
class OperationRelation(Base):
"""Links two operations — e.g., the debit and credit legs of a transfer."""
__tablename__ = "operation_relations"
id: Mapped[int] = mapped_column(primary_key=True)
operation_from_id: Mapped[int] = mapped_column(ForeignKey("operations.id"), nullable=False)
operation_to_id: Mapped[int] = mapped_column(ForeignKey("operations.id"), nullable=False)
relation_type: Mapped[str] = mapped_column(String(50), nullable=False) # transfer | split | correction
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
operation_from: Mapped["Operation"] = relationship(
"Operation", foreign_keys=[operation_from_id], back_populates="relations_from"
)
operation_to: Mapped["Operation"] = relationship(
"Operation", foreign_keys=[operation_to_id], back_populates="relations_to"
)

View File

@@ -0,0 +1,38 @@
from datetime import datetime
from sqlalchemy import String, Boolean, ForeignKey, DateTime, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Role(Base):
__tablename__ = "roles"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
description: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
users: Mapped[list["User"]] = relationship("User", back_populates="role")
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role_id: Mapped[int] = mapped_column(ForeignKey("roles.id"), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
role: Mapped["Role"] = relationship("Role", back_populates="users")
accounts: Mapped[list] = relationship("Account", back_populates="user")
operations: Mapped[list] = relationship("Operation", back_populates="user")
budgets: Mapped[list] = relationship("Budget", back_populates="user")
categories: Mapped[list] = relationship("Category", back_populates="user")
calendar_events: Mapped[list] = relationship("CalendarEvent", back_populates="user")
locations: Mapped[list] = relationship("Location", back_populates="user")

View File

@@ -0,0 +1,29 @@
from app.schemas.user import RoleOut, UserCreate, UserUpdate, UserOut, UserWithRole
from app.schemas.currency import CurrencyCreate, CurrencyUpdate, CurrencyOut, ExchangeRateCreate, ExchangeRateOut
from app.schemas.location import LocationCreate, LocationUpdate, LocationOut
from app.schemas.account import AccountTypeOut, AccountCreate, AccountUpdate, AccountOut
from app.schemas.category import CategoryCreate, CategoryUpdate, CategoryOut
from app.schemas.budget import BudgetCreate, BudgetUpdate, BudgetOut
from app.schemas.operation import (
OperationTypeOut,
OperationCreate, OperationUpdate, OperationOut,
OperationRelationCreate, OperationRelationOut,
TransferCreate,
)
from app.schemas.calendar import CalendarEventCreate, CalendarEventUpdate, CalendarEventOut
from app.schemas.auth import Token, TokenData, LoginRequest
__all__ = [
"RoleOut", "UserCreate", "UserUpdate", "UserOut", "UserWithRole",
"CurrencyCreate", "CurrencyUpdate", "CurrencyOut",
"ExchangeRateCreate", "ExchangeRateOut",
"LocationCreate", "LocationUpdate", "LocationOut",
"AccountTypeOut", "AccountCreate", "AccountUpdate", "AccountOut",
"CategoryCreate", "CategoryUpdate", "CategoryOut",
"BudgetCreate", "BudgetUpdate", "BudgetOut",
"OperationTypeOut",
"OperationCreate", "OperationUpdate", "OperationOut",
"OperationRelationCreate", "OperationRelationOut", "TransferCreate",
"CalendarEventCreate", "CalendarEventUpdate", "CalendarEventOut",
"Token", "TokenData", "LoginRequest",
]

View File

@@ -0,0 +1,53 @@
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, Field
from app.schemas.currency import CurrencyOut
class AccountTypeOut(BaseModel):
id: int
name: str
icon: str | None = None
description: str | None = None
model_config = {"from_attributes": True}
class AccountCreate(BaseModel):
name: str = Field(..., max_length=200)
account_type_id: int
currency_id: int
initial_balance: Decimal = Decimal("0.00")
credit_limit: Decimal | None = None
description: str | None = None
icon: str | None = None
color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
class AccountUpdate(BaseModel):
name: str | None = Field(None, max_length=200)
is_active: bool | None = None
credit_limit: Decimal | None = None
description: str | None = None
icon: str | None = None
color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
class AccountOut(BaseModel):
id: int
user_id: int
name: str
account_type_id: int
account_type: AccountTypeOut
currency_id: int
currency: CurrencyOut
balance: Decimal
initial_balance: Decimal
is_active: bool
credit_limit: Decimal | None = None
description: str | None = None
icon: str | None = None
color: str | None = None
created_at: datetime
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,17 @@
from pydantic import BaseModel
class LoginRequest(BaseModel):
username: str
password: str
class Token(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class TokenData(BaseModel):
user_id: int | None = None
role: str | None = None

View File

@@ -0,0 +1,43 @@
from datetime import datetime, date
from decimal import Decimal
from pydantic import BaseModel, Field
from app.schemas.category import CategoryOut
from app.schemas.currency import CurrencyOut
class BudgetCreate(BaseModel):
category_id: int
name: str = Field(..., max_length=200)
amount: Decimal = Field(..., gt=0)
currency_id: int
period_type: str = Field(..., pattern="^(monthly|weekly|yearly|custom)$")
start_date: date
end_date: date | None = None
is_active: bool = True
class BudgetUpdate(BaseModel):
name: str | None = None
amount: Decimal | None = Field(None, gt=0)
period_type: str | None = None
end_date: date | None = None
is_active: bool | None = None
class BudgetOut(BaseModel):
id: int
user_id: int
category_id: int
category: CategoryOut
name: str
amount: Decimal
currency_id: int
currency: CurrencyOut
period_type: str
start_date: date
end_date: date | None = None
is_active: bool
created_at: datetime
spent: Decimal | None = None # computed field
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,55 @@
from datetime import datetime, date, time
from decimal import Decimal
from pydantic import BaseModel, Field
class CalendarEventCreate(BaseModel):
title: str = Field(..., max_length=200)
description: str | None = None
event_date: date
event_time: time | None = None
is_recurring: bool = False
recurrence_pattern: str | None = Field(None, pattern="^(daily|weekly|monthly|yearly)$")
recurrence_end_date: date | None = None
related_operation_id: int | None = None
category_id: int | None = None
amount: Decimal | None = None
currency_id: int | None = None
reminder_days: int | None = Field(None, ge=0)
is_completed: bool = False
class CalendarEventUpdate(BaseModel):
title: str | None = Field(None, max_length=200)
description: str | None = None
event_date: date | None = None
event_time: time | None = None
is_recurring: bool | None = None
recurrence_pattern: str | None = None
recurrence_end_date: date | None = None
related_operation_id: int | None = None
category_id: int | None = None
amount: Decimal | None = None
reminder_days: int | None = None
is_completed: bool | None = None
class CalendarEventOut(BaseModel):
id: int
user_id: int
title: str
description: str | None = None
event_date: date
event_time: time | None = None
is_recurring: bool
recurrence_pattern: str | None = None
recurrence_end_date: date | None = None
related_operation_id: int | None = None
category_id: int | None = None
amount: Decimal | None = None
currency_id: int | None = None
reminder_days: int | None = None
is_completed: bool
created_at: datetime
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,30 @@
from datetime import datetime
from pydantic import BaseModel, Field
class CategoryCreate(BaseModel):
name: str = Field(..., max_length=200)
type: str = Field(..., pattern="^(income|expense|transfer)$")
parent_id: int | None = None
icon: str | None = None
color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
class CategoryUpdate(BaseModel):
name: str | None = Field(None, max_length=200)
parent_id: int | None = None
icon: str | None = None
color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
class CategoryOut(BaseModel):
id: int
user_id: int | None = None
name: str
type: str
parent_id: int | None = None
icon: str | None = None
color: str | None = None
created_at: datetime
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,48 @@
from datetime import datetime, date
from decimal import Decimal
from pydantic import BaseModel, Field
class CurrencyCreate(BaseModel):
code: str = Field(..., min_length=3, max_length=3)
name: str = Field(..., max_length=100)
symbol: str | None = Field(None, max_length=10)
is_active: bool = True
class CurrencyUpdate(BaseModel):
name: str | None = Field(None, max_length=100)
symbol: str | None = Field(None, max_length=10)
is_active: bool | None = None
class CurrencyOut(BaseModel):
id: int
code: str
name: str
symbol: str | None = None
is_active: bool
model_config = {"from_attributes": True}
class ExchangeRateCreate(BaseModel):
currency_from_id: int
currency_to_id: int
rate: Decimal = Field(..., gt=0)
rate_date: date
source: str | None = None
class ExchangeRateOut(BaseModel):
id: int
currency_from_id: int
currency_to_id: int
rate: Decimal
rate_date: date
source: str | None = None
created_at: datetime
currency_from: CurrencyOut
currency_to: CurrencyOut
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,41 @@
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, Field
class LocationCreate(BaseModel):
name: str = Field(..., max_length=200)
type: str | None = Field(None, max_length=50)
address: str | None = None
city: str | None = Field(None, max_length=100)
country: str | None = Field(None, max_length=100)
latitude: Decimal | None = None
longitude: Decimal | None = None
parent_id: int | None = None
class LocationUpdate(BaseModel):
name: str | None = Field(None, max_length=200)
type: str | None = None
address: str | None = None
city: str | None = None
country: str | None = None
latitude: Decimal | None = None
longitude: Decimal | None = None
parent_id: int | None = None
class LocationOut(BaseModel):
id: int
user_id: int | None = None
name: str
type: str | None = None
address: str | None = None
city: str | None = None
country: str | None = None
latitude: Decimal | None = None
longitude: Decimal | None = None
parent_id: int | None = None
created_at: datetime
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,89 @@
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, Field
from app.schemas.currency import CurrencyOut
from app.schemas.category import CategoryOut
from app.schemas.location import LocationOut
class OperationTypeOut(BaseModel):
id: int
name: str
description: str | None = None
model_config = {"from_attributes": True}
class OperationCreate(BaseModel):
account_id: int
category_id: int | None = None
operation_type_id: int
amount: Decimal = Field(..., gt=0)
currency_id: int
amount_converted: Decimal | None = None
exchange_rate: Decimal | None = None
date: datetime
description: str | None = None
location_id: int | None = None
is_planned: bool = False
class OperationUpdate(BaseModel):
category_id: int | None = None
amount: Decimal | None = Field(None, gt=0)
date: datetime | None = None
description: str | None = None
location_id: int | None = None
is_planned: bool | None = None
class OperationOut(BaseModel):
id: int
user_id: int
account_id: int
category_id: int | None = None
category: CategoryOut | None = None
operation_type_id: int
operation_type: OperationTypeOut
amount: Decimal
currency_id: int
currency: CurrencyOut
amount_converted: Decimal | None = None
exchange_rate: Decimal | None = None
date: datetime
description: str | None = None
location_id: int | None = None
location: LocationOut | None = None
is_planned: bool
created_at: datetime
model_config = {"from_attributes": True}
class OperationRelationCreate(BaseModel):
operation_from_id: int
operation_to_id: int
relation_type: str = Field(..., pattern="^(transfer|split|correction)$")
class OperationRelationOut(BaseModel):
id: int
operation_from_id: int
operation_to_id: int
relation_type: str
created_at: datetime
model_config = {"from_attributes": True}
class TransferCreate(BaseModel):
"""Convenience schema: creates two linked operations in one request."""
from_account_id: int
to_account_id: int
amount: Decimal = Field(..., gt=0)
currency_id: int
amount_converted: Decimal | None = None
exchange_rate: Decimal | None = None
date: datetime
description: str | None = None
location_id: int | None = None

View File

@@ -0,0 +1,39 @@
from datetime import datetime
from pydantic import BaseModel, EmailStr, Field
class RoleOut(BaseModel):
id: int
name: str
description: str | None = None
model_config = {"from_attributes": True}
class UserCreate(BaseModel):
username: str = Field(..., min_length=3, max_length=100)
email: EmailStr
password: str = Field(..., min_length=8)
role_id: int = 3 # default: user
class UserUpdate(BaseModel):
username: str | None = Field(None, min_length=3, max_length=100)
email: EmailStr | None = None
is_active: bool | None = None
role_id: int | None = None
class UserOut(BaseModel):
id: int
username: str
email: str
role_id: int
is_active: bool
created_at: datetime
model_config = {"from_attributes": True}
class UserWithRole(UserOut):
role: RoleOut