Обновление приложения
This commit is contained in:
18
backend/.env.example
Normal file
18
backend/.env.example
Normal file
@@ -0,0 +1,18 @@
|
||||
# Copy to .env and fill in all values — never commit .env
|
||||
|
||||
# PostgreSQL
|
||||
POSTGRES_DB=financedb
|
||||
POSTGRES_USER=financeuser
|
||||
POSTGRES_PASSWORD=CHANGE_ME_STRONG_PASSWORD
|
||||
|
||||
# Full DSN used by SQLAlchemy (set automatically in docker-compose, or set manually)
|
||||
DATABASE_URL=postgresql://financeuser:CHANGE_ME@localhost:5432/financedb
|
||||
|
||||
# JWT — generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
SECRET_KEY=CHANGE_ME_32_BYTES_HEX
|
||||
|
||||
# CORS — comma-separated origins allowed to call the API
|
||||
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
|
||||
|
||||
# App
|
||||
DEBUG=false
|
||||
15
backend/Dockerfile
Normal file
15
backend/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq-dev gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# Run migrations then seed then start server
|
||||
CMD ["sh", "-c", "alembic upgrade head && python seed.py && uvicorn app.main:app --host 0.0.0.0 --port 8000"]
|
||||
38
backend/alembic.ini
Normal file
38
backend/alembic.ini
Normal file
@@ -0,0 +1,38 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
44
backend/alembic/env.py
Normal file
44
backend/alembic/env.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from alembic import context
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# Import all models so Alembic sees them
|
||||
from app.database import Base
|
||||
import app.models # noqa: F401
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", config.get_main_option("sqlalchemy.url", ""))
|
||||
if DATABASE_URL:
|
||||
config.set_main_option("sqlalchemy.url", DATABASE_URL)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"})
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
211
backend/alembic/versions/001_initial_schema.py
Normal file
211
backend/alembic/versions/001_initial_schema.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""Initial schema
|
||||
|
||||
Revision ID: 001
|
||||
Revises:
|
||||
Create Date: 2026-04-28
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "001"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"roles",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("name", sa.String(50), unique=True, nullable=False),
|
||||
sa.Column("description", sa.Text),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("username", sa.String(100), unique=True, nullable=False),
|
||||
sa.Column("email", sa.String(255), unique=True, nullable=False),
|
||||
sa.Column("password_hash", sa.String(255), nullable=False),
|
||||
sa.Column("role_id", sa.Integer, sa.ForeignKey("roles.id"), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean, default=True, nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_users_username", "users", ["username"])
|
||||
op.create_index("ix_users_email", "users", ["email"])
|
||||
|
||||
op.create_table(
|
||||
"currencies",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("code", sa.String(3), unique=True, nullable=False),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column("symbol", sa.String(10)),
|
||||
sa.Column("is_active", sa.Boolean, default=True, nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"exchange_rates",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("currency_from_id", sa.Integer, sa.ForeignKey("currencies.id"), nullable=False),
|
||||
sa.Column("currency_to_id", sa.Integer, sa.ForeignKey("currencies.id"), nullable=False),
|
||||
sa.Column("rate", sa.Numeric(20, 6), nullable=False),
|
||||
sa.Column("rate_date", sa.Date, nullable=False),
|
||||
sa.Column("source", sa.String(100)),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("currency_from_id", "currency_to_id", "rate_date", name="uq_exchange_rate_day"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"locations",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id"), nullable=True),
|
||||
sa.Column("name", sa.String(200), nullable=False),
|
||||
sa.Column("type", sa.String(50)),
|
||||
sa.Column("address", sa.Text),
|
||||
sa.Column("city", sa.String(100)),
|
||||
sa.Column("country", sa.String(100)),
|
||||
sa.Column("latitude", sa.Numeric(9, 6)),
|
||||
sa.Column("longitude", sa.Numeric(9, 6)),
|
||||
sa.Column("parent_id", sa.Integer, sa.ForeignKey("locations.id"), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"account_types",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("name", sa.String(100), unique=True, nullable=False),
|
||||
sa.Column("icon", sa.String(50)),
|
||||
sa.Column("description", sa.Text),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"accounts",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("name", sa.String(200), nullable=False),
|
||||
sa.Column("account_type_id", sa.Integer, sa.ForeignKey("account_types.id"), nullable=False),
|
||||
sa.Column("currency_id", sa.Integer, sa.ForeignKey("currencies.id"), nullable=False),
|
||||
sa.Column("balance", sa.Numeric(20, 2), default=0, nullable=False),
|
||||
sa.Column("initial_balance", sa.Numeric(20, 2), default=0, nullable=False),
|
||||
sa.Column("is_active", sa.Boolean, default=True, nullable=False),
|
||||
sa.Column("credit_limit", sa.Numeric(20, 2)),
|
||||
sa.Column("description", sa.Text),
|
||||
sa.Column("icon", sa.String(50)),
|
||||
sa.Column("color", sa.String(7)),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_accounts_user_id", "accounts", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"categories",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id"), nullable=True),
|
||||
sa.Column("name", sa.String(200), nullable=False),
|
||||
sa.Column("type", sa.String(20), nullable=False),
|
||||
sa.Column("parent_id", sa.Integer, sa.ForeignKey("categories.id"), nullable=True),
|
||||
sa.Column("icon", sa.String(50)),
|
||||
sa.Column("color", sa.String(7)),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_categories_user_id", "categories", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"budgets",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("category_id", sa.Integer, sa.ForeignKey("categories.id"), nullable=False),
|
||||
sa.Column("name", sa.String(200), nullable=False),
|
||||
sa.Column("amount", sa.Numeric(20, 2), nullable=False),
|
||||
sa.Column("currency_id", sa.Integer, sa.ForeignKey("currencies.id"), nullable=False),
|
||||
sa.Column("period_type", sa.String(20), nullable=False),
|
||||
sa.Column("start_date", sa.Date, nullable=False),
|
||||
sa.Column("end_date", sa.Date),
|
||||
sa.Column("is_active", sa.Boolean, default=True, nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_budgets_user_id", "budgets", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"operation_types",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("name", sa.String(50), unique=True, nullable=False),
|
||||
sa.Column("description", sa.Text),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"operations",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("account_id", sa.Integer, sa.ForeignKey("accounts.id"), nullable=False),
|
||||
sa.Column("category_id", sa.Integer, sa.ForeignKey("categories.id"), nullable=True),
|
||||
sa.Column("operation_type_id", sa.Integer, sa.ForeignKey("operation_types.id"), nullable=False),
|
||||
sa.Column("amount", sa.Numeric(20, 2), nullable=False),
|
||||
sa.Column("currency_id", sa.Integer, sa.ForeignKey("currencies.id"), nullable=False),
|
||||
sa.Column("amount_converted", sa.Numeric(20, 2)),
|
||||
sa.Column("exchange_rate", sa.Numeric(20, 6)),
|
||||
sa.Column("date", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("description", sa.Text),
|
||||
sa.Column("location_id", sa.Integer, sa.ForeignKey("locations.id"), nullable=True),
|
||||
sa.Column("is_planned", sa.Boolean, default=False, nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_operations_user_id", "operations", ["user_id"])
|
||||
op.create_index("ix_operations_account_id", "operations", ["account_id"])
|
||||
op.create_index("ix_operations_date", "operations", ["date"])
|
||||
|
||||
op.create_table(
|
||||
"operation_relations",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("operation_from_id", sa.Integer, sa.ForeignKey("operations.id"), nullable=False),
|
||||
sa.Column("operation_to_id", sa.Integer, sa.ForeignKey("operations.id"), nullable=False),
|
||||
sa.Column("relation_type", sa.String(50), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"calendar_events",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("title", sa.String(200), nullable=False),
|
||||
sa.Column("description", sa.Text),
|
||||
sa.Column("event_date", sa.Date, nullable=False),
|
||||
sa.Column("event_time", sa.Time),
|
||||
sa.Column("is_recurring", sa.Boolean, default=False, nullable=False),
|
||||
sa.Column("recurrence_pattern", sa.String(50)),
|
||||
sa.Column("recurrence_end_date", sa.Date),
|
||||
sa.Column("related_operation_id", sa.Integer, sa.ForeignKey("operations.id"), nullable=True),
|
||||
sa.Column("category_id", sa.Integer, sa.ForeignKey("categories.id"), nullable=True),
|
||||
sa.Column("amount", sa.Numeric(20, 2)),
|
||||
sa.Column("currency_id", sa.Integer, sa.ForeignKey("currencies.id"), nullable=True),
|
||||
sa.Column("reminder_days", sa.Integer),
|
||||
sa.Column("is_completed", sa.Boolean, default=False, nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_calendar_events_user_id", "calendar_events", ["user_id"])
|
||||
op.create_index("ix_calendar_events_event_date", "calendar_events", ["event_date"])
|
||||
|
||||
# Grant permissions to app user (runs only if the role exists)
|
||||
op.execute("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'finance_app_user') THEN
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO finance_app_user;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO finance_app_user;
|
||||
END IF;
|
||||
END$$;
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in [
|
||||
"calendar_events", "operation_relations", "operations", "operation_types",
|
||||
"budgets", "categories", "accounts", "account_types",
|
||||
"locations", "exchange_rates", "currencies", "users", "roles",
|
||||
]:
|
||||
op.drop_table(table)
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
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()
|
||||
0
backend/app/auth/__init__.py
Normal file
0
backend/app/auth/__init__.py
Normal file
54
backend/app/auth/dependencies.py
Normal file
54
backend/app/auth/dependencies.py
Normal 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"))
|
||||
41
backend/app/auth/security.py
Normal file
41
backend/app/auth/security.py
Normal 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
25
backend/app/config.py
Normal 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
33
backend/app/database.py
Normal 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
29
backend/app/main.py
Normal 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"}
|
||||
19
backend/app/models/__init__.py
Normal file
19
backend/app/models/__init__.py
Normal 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",
|
||||
]
|
||||
42
backend/app/models/account.py
Normal file
42
backend/app/models/account.py
Normal 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")
|
||||
25
backend/app/models/budget.py
Normal file
25
backend/app/models/budget.py
Normal 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")
|
||||
34
backend/app/models/calendar.py
Normal file
34
backend/app/models/calendar.py
Normal 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")
|
||||
26
backend/app/models/category.py
Normal file
26
backend/app/models/category.py
Normal 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")
|
||||
49
backend/app/models/currency.py
Normal file
49
backend/app/models/currency.py
Normal 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"
|
||||
)
|
||||
26
backend/app/models/location.py
Normal file
26
backend/app/models/location.py
Normal 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")
|
||||
70
backend/app/models/operation.py
Normal file
70
backend/app/models/operation.py
Normal 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"
|
||||
)
|
||||
38
backend/app/models/user.py
Normal file
38
backend/app/models/user.py
Normal 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")
|
||||
29
backend/app/schemas/__init__.py
Normal file
29
backend/app/schemas/__init__.py
Normal 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",
|
||||
]
|
||||
53
backend/app/schemas/account.py
Normal file
53
backend/app/schemas/account.py
Normal 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}
|
||||
17
backend/app/schemas/auth.py
Normal file
17
backend/app/schemas/auth.py
Normal 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
|
||||
43
backend/app/schemas/budget.py
Normal file
43
backend/app/schemas/budget.py
Normal 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}
|
||||
55
backend/app/schemas/calendar.py
Normal file
55
backend/app/schemas/calendar.py
Normal 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}
|
||||
30
backend/app/schemas/category.py
Normal file
30
backend/app/schemas/category.py
Normal 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}
|
||||
48
backend/app/schemas/currency.py
Normal file
48
backend/app/schemas/currency.py
Normal 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}
|
||||
41
backend/app/schemas/location.py
Normal file
41
backend/app/schemas/location.py
Normal 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}
|
||||
89
backend/app/schemas/operation.py
Normal file
89
backend/app/schemas/operation.py
Normal 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
|
||||
39
backend/app/schemas/user.py
Normal file
39
backend/app/schemas/user.py
Normal 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
|
||||
12
backend/requirements.txt
Normal file
12
backend/requirements.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
fastapi==0.111.0
|
||||
uvicorn[standard]==0.29.0
|
||||
sqlalchemy==2.0.30
|
||||
alembic==1.13.1
|
||||
psycopg2-binary==2.9.9
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-multipart==0.0.9
|
||||
pydantic-settings==2.2.1
|
||||
pydantic[email]==2.7.1
|
||||
httpx==0.27.0
|
||||
python-dotenv==1.0.1
|
||||
121
backend/seed.py
Normal file
121
backend/seed.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Seed initial reference data. Run once after migrations: python seed.py"""
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from app.auth.security import hash_password
|
||||
from app.models import Role, User, Currency, AccountType, OperationType, Category
|
||||
|
||||
engine = create_engine(os.environ["DATABASE_URL"])
|
||||
|
||||
ROLES = [
|
||||
("admin", "Full access — manages users, all financial data"),
|
||||
("accountant", "Manages all financial data, cannot manage users"),
|
||||
("user", "Manages own financial data"),
|
||||
("viewer", "Read-only access to own data"),
|
||||
]
|
||||
|
||||
CURRENCIES = [
|
||||
("RUB", "Российский рубль", "₽"),
|
||||
("USD", "Доллар США", "$"),
|
||||
("EUR", "Евро", "€"),
|
||||
("GBP", "Фунт стерлингов", "£"),
|
||||
("CNY", "Китайский юань", "¥"),
|
||||
("BTC", "Биткоин", "₿"),
|
||||
]
|
||||
|
||||
ACCOUNT_TYPES = [
|
||||
("Наличные", "wallet", "Наличные деньги"),
|
||||
("Банковский счёт", "bank", "Текущий/расчётный счёт"),
|
||||
("Кредитная карта", "credit-card", "Кредитная карта"),
|
||||
("Накопительный", "piggy-bank", "Вклад / накопительный счёт"),
|
||||
("Инвестиционный", "chart-line", "Брокерский или инвест. счёт"),
|
||||
("Криптовалюта", "bitcoin", "Криптовалютный кошелёк"),
|
||||
("Другое", "dots", "Прочий тип счёта"),
|
||||
]
|
||||
|
||||
OPERATION_TYPES = [
|
||||
("income", "Доход — пополнение счёта"),
|
||||
("expense", "Расход — списание со счёта"),
|
||||
("transfer", "Перевод между счетами"),
|
||||
]
|
||||
|
||||
CATEGORIES = [
|
||||
# (name, type, icon, color)
|
||||
# --- Доходы ---
|
||||
("Зарплата", "income", "briefcase", "#4CAF50"),
|
||||
("Фриланс", "income", "laptop", "#8BC34A"),
|
||||
("Инвестиции", "income", "chart-line", "#00BCD4"),
|
||||
("Подарки", "income", "gift", "#E91E63"),
|
||||
("Прочие доходы", "income", "plus-circle", "#9E9E9E"),
|
||||
# --- Расходы ---
|
||||
("Продукты", "expense", "shopping-cart","#FF5722"),
|
||||
("Транспорт", "expense", "car", "#FF9800"),
|
||||
("ЖКХ", "expense", "home", "#795548"),
|
||||
("Здоровье", "expense", "heart", "#F44336"),
|
||||
("Рестораны и кафе", "expense", "utensils", "#E91E63"),
|
||||
("Развлечения", "expense", "gamepad", "#9C27B0"),
|
||||
("Одежда", "expense", "tshirt", "#3F51B5"),
|
||||
("Образование", "expense", "book", "#2196F3"),
|
||||
("Путешествия", "expense", "plane", "#03A9F4"),
|
||||
("Связь и интернет", "expense", "wifi", "#009688"),
|
||||
("Подписки", "expense", "repeat", "#607D8B"),
|
||||
("Прочие расходы", "expense", "minus-circle", "#9E9E9E"),
|
||||
# --- Переводы ---
|
||||
("Переводы", "transfer", "arrows-h", "#607D8B"),
|
||||
]
|
||||
|
||||
|
||||
def seed():
|
||||
with Session(engine) as db:
|
||||
# Roles
|
||||
role_map: dict[str, Role] = {}
|
||||
for name, desc in ROLES:
|
||||
role = db.query(Role).filter(Role.name == name).first()
|
||||
if not role:
|
||||
role = Role(name=name, description=desc)
|
||||
db.add(role)
|
||||
db.flush()
|
||||
role_map[name] = role
|
||||
|
||||
# Admin user
|
||||
if not db.query(User).filter(User.username == "admin").first():
|
||||
admin_pwd = os.getenv("ADMIN_PASSWORD", "Admin1234!")
|
||||
admin = User(
|
||||
username="admin",
|
||||
email="admin@finance.local",
|
||||
password_hash=hash_password(admin_pwd),
|
||||
role_id=role_map["admin"].id,
|
||||
)
|
||||
db.add(admin)
|
||||
print(f"[seed] Admin user created. Password from ADMIN_PASSWORD env (default: Admin1234!)")
|
||||
|
||||
# Currencies
|
||||
for code, name, symbol in CURRENCIES:
|
||||
if not db.query(Currency).filter(Currency.code == code).first():
|
||||
db.add(Currency(code=code, name=name, symbol=symbol))
|
||||
|
||||
# Account types
|
||||
for name, icon, desc in ACCOUNT_TYPES:
|
||||
if not db.query(AccountType).filter(AccountType.name == name).first():
|
||||
db.add(AccountType(name=name, icon=icon, description=desc))
|
||||
|
||||
# Operation types
|
||||
for name, desc in OPERATION_TYPES:
|
||||
if not db.query(OperationType).filter(OperationType.name == name).first():
|
||||
db.add(OperationType(name=name, description=desc))
|
||||
|
||||
# System categories (user_id=None)
|
||||
for name, ctype, icon, color in CATEGORIES:
|
||||
if not db.query(Category).filter(Category.name == name, Category.user_id == None).first():
|
||||
db.add(Category(name=name, type=ctype, icon=icon, color=color, user_id=None))
|
||||
|
||||
db.commit()
|
||||
print("[seed] Done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
seed()
|
||||
9
backend/sql/init_roles.sql
Normal file
9
backend/sql/init_roles.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- PostgreSQL DB-level roles for connection security
|
||||
-- App uses a single app user; RLS enforces row-level isolation
|
||||
|
||||
CREATE ROLE finance_app_user WITH LOGIN PASSWORD 'CHANGE_IN_ENV' NOSUPERUSER NOCREATEDB NOCREATEROLE;
|
||||
GRANT CONNECT ON DATABASE financedb TO finance_app_user;
|
||||
GRANT USAGE ON SCHEMA public TO finance_app_user;
|
||||
|
||||
-- Permissions granted after table creation via Alembic migration
|
||||
-- See backend/alembic/versions/001_initial_schema.py for GRANT statements
|
||||
Reference in New Issue
Block a user