sync: migrate secure-online-shop to Gitea (2026-08-10)

This commit is contained in:
konturai-ops
2026-08-10 15:26:59 +00:00
commit 2022c8890d
44 changed files with 3196 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Service layer package."""
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
import hashlib
import logging
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.exceptions import ConflictError, UnauthorizedError
from app.core.security import create_access_token, get_password_hash, verify_password
from app.models.user import RoleEnum, User
from app.schemas.auth import LoginRequest, UserCreate
audit_logger = logging.getLogger("app.audit")
class AuthService:
def __init__(self, db: Session) -> None:
self.db = db
def register_user(self, payload: UserCreate) -> User:
existing_user = self.db.scalar(select(User).where(User.username == payload.username))
if existing_user is not None:
raise ConflictError("Username already exists")
user = User(
username=payload.username,
password_hash=get_password_hash(payload.password),
role=RoleEnum.client,
is_active=True,
)
self.db.add(user)
try:
self.db.commit()
self.db.refresh(user)
except IntegrityError as exc:
self.db.rollback()
raise ConflictError("Username already exists") from exc
audit_logger.info("user_registered user_id=%s role=%s", user.id, user.role.value)
return user
def authenticate_user(self, payload: LoginRequest) -> str:
user = self.db.scalar(select(User).where(User.username == payload.username))
subject_hash = self._hash_subject(payload.username)
if user is None or not verify_password(payload.password, user.password_hash):
audit_logger.warning("login_failed subject_hash=%s", subject_hash)
raise UnauthorizedError("Invalid username or password")
if not user.is_active:
audit_logger.warning("login_rejected_inactive user_id=%s", user.id)
raise UnauthorizedError("Invalid username or password")
access_token = create_access_token(subject=str(user.id))
audit_logger.info("login_success user_id=%s role=%s", user.id, user.role.value)
return access_token
def get_user_by_id(self, user_id: int) -> User | None:
return self.db.get(User, user_id)
@staticmethod
def _hash_subject(subject: str) -> str:
return hashlib.sha256(subject.encode("utf-8")).hexdigest()[:12]
+104
View File
@@ -0,0 +1,104 @@
from __future__ import annotations
import logging
from decimal import Decimal, ROUND_HALF_UP
from sqlalchemy import select, update
from sqlalchemy.orm import Session, selectinload
from app.core.exceptions import BadRequestError, NotFoundError
from app.models.order import Order, OrderStatusEnum
from app.models.order_item import OrderItem
from app.models.product import Product
from app.models.user import User
from app.schemas.order import OrderCreate
audit_logger = logging.getLogger("app.audit")
MONEY_QUANTIZER = Decimal("0.01")
def _to_money(value: Decimal) -> Decimal:
return value.quantize(MONEY_QUANTIZER, rounding=ROUND_HALF_UP)
class OrderService:
def __init__(self, db: Session) -> None:
self.db = db
def create_order(self, user: User, payload: OrderCreate) -> Order:
product_ids = [item.product_id for item in payload.items]
products = self.db.scalars(
select(Product).where(Product.id.in_(product_ids), Product.is_active.is_(True))
).all()
if len(products) != len(product_ids):
raise NotFoundError("One or more requested products are unavailable")
product_map = {product.id: product for product in products}
order = Order(user_id=user.id, total_amount=Decimal("0.00"), status=OrderStatusEnum.pending)
try:
self.db.add(order)
self.db.flush()
total_amount = Decimal("0.00")
for item in payload.items:
product = product_map[item.product_id]
update_result = self.db.execute(
update(Product)
.where(
Product.id == product.id,
Product.is_active.is_(True),
Product.stock >= item.quantity,
)
.values(stock=Product.stock - item.quantity)
)
if update_result.rowcount != 1:
raise BadRequestError(
f"Insufficient stock for product '{product.name}'"
)
unit_price = _to_money(Decimal(product.price))
subtotal = _to_money(unit_price * item.quantity)
order_item = OrderItem(
order_id=order.id,
product_id=product.id,
quantity=item.quantity,
unit_price=unit_price,
subtotal=subtotal,
)
self.db.add(order_item)
total_amount += subtotal
order.total_amount = _to_money(total_amount)
self.db.commit()
except Exception:
self.db.rollback()
raise
audit_logger.info(
"order_created order_id=%s user_id=%s total_amount=%s",
order.id,
user.id,
str(order.total_amount),
)
return self.get_order_for_user(order.id, user)
def get_orders_for_user(self, user: User) -> list[Order]:
statement = (
select(Order)
.where(Order.user_id == user.id)
.options(selectinload(Order.items).selectinload(OrderItem.product))
.order_by(Order.created_at.desc())
)
return list(self.db.scalars(statement).all())
def get_order_for_user(self, order_id: int, user: User) -> Order:
statement = (
select(Order)
.where(Order.id == order_id, Order.user_id == user.id)
.options(selectinload(Order.items).selectinload(OrderItem.product))
)
order = self.db.scalar(statement)
if order is None:
raise NotFoundError("Order not found")
return order
+41
View File
@@ -0,0 +1,41 @@
from __future__ import annotations
import logging
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from app.core.exceptions import BadRequestError
from app.models.order import Order, OrderStatusEnum
from app.models.user import User
from app.services.order_service import OrderService
audit_logger = logging.getLogger("app.audit")
class PaymentService:
def __init__(self, db: Session) -> None:
self.db = db
self.order_service = OrderService(db)
def confirm_payment(self, order_id: int, user: User) -> Order:
order = self.order_service.get_order_for_user(order_id, user)
if order.status != OrderStatusEnum.pending:
raise BadRequestError("This order is not eligible for payment")
try:
order.status = OrderStatusEnum.paid
order.paid_at = datetime.now(timezone.utc)
self.db.commit()
except Exception:
self.db.rollback()
raise
audit_logger.info(
"payment_confirmed order_id=%s user_id=%s total_amount=%s",
order.id,
user.id,
str(order.total_amount),
)
return self.order_service.get_order_for_user(order.id, user)
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
import logging
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.exceptions import ForbiddenError
from app.models.product import Product
from app.models.user import RoleEnum, User
from app.schemas.product import ProductCreate
audit_logger = logging.getLogger("app.audit")
class ProductService:
def __init__(self, db: Session) -> None:
self.db = db
def list_available_products(self) -> list[Product]:
statement = (
select(Product)
.where(Product.is_active.is_(True), Product.stock > 0)
.order_by(Product.created_at.desc())
)
return list(self.db.scalars(statement).all())
def create_product(self, payload: ProductCreate, actor: User) -> Product:
if actor.role != RoleEnum.shop:
raise ForbiddenError("Shop privileges are required")
product = Product(
name=payload.name,
description=payload.description,
price=payload.price,
stock=payload.stock,
is_active=payload.is_active,
)
self.db.add(product)
try:
self.db.commit()
self.db.refresh(product)
except Exception:
self.db.rollback()
raise
audit_logger.info("product_created product_id=%s shop_user_id=%s", product.id, actor.id)
return product