Обновление приложения
This commit is contained in:
0
backend/app/api/v1/__init__.py
Normal file
0
backend/app/api/v1/__init__.py
Normal file
64
backend/app/api/v1/accounts.py
Normal file
64
backend/app/api/v1/accounts.py
Normal 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()
|
||||
77
backend/app/api/v1/auth.py
Normal file
77
backend/app/api/v1/auth.py
Normal 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
|
||||
80
backend/app/api/v1/budgets.py
Normal file
80
backend/app/api/v1/budgets.py
Normal 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()
|
||||
73
backend/app/api/v1/calendar.py
Normal file
73
backend/app/api/v1/calendar.py
Normal 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()
|
||||
49
backend/app/api/v1/categories.py
Normal file
49
backend/app/api/v1/categories.py
Normal 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()
|
||||
55
backend/app/api/v1/currencies.py
Normal file
55
backend/app/api/v1/currencies.py
Normal 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()
|
||||
61
backend/app/api/v1/exchange_rates.py
Normal file
61
backend/app/api/v1/exchange_rates.py
Normal 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()
|
||||
49
backend/app/api/v1/locations.py
Normal file
49
backend/app/api/v1/locations.py
Normal 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()
|
||||
163
backend/app/api/v1/operations.py
Normal file
163
backend/app/api/v1/operations.py
Normal 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()
|
||||
51
backend/app/api/v1/relations.py
Normal file
51
backend/app/api/v1/relations.py
Normal 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()
|
||||
15
backend/app/api/v1/router.py
Normal file
15
backend/app/api/v1/router.py
Normal 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)
|
||||
52
backend/app/api/v1/users.py
Normal file
52
backend/app/api/v1/users.py
Normal 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()
|
||||
Reference in New Issue
Block a user