commit 2022c8890ddc2c5979f98fa785f3c1fd242e8c8f Author: konturai-ops Date: Mon Aug 10 15:26:59 2026 +0000 sync: migrate secure-online-shop to Gitea (2026-08-10) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e3f3919 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +.env +.idea +.mypy_cache +.ruff_cache +.qwen +.venv-audit +.codex-venv +__pycache__ +*.pyc +*.pyo +*.db +*.log +venv +tests +assignment5_artifacts +report_assets +*.docx +*.tmp +*-report*.json +*-report*.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5e13e01 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +source.tar.gz +._* +app/__pycache__/ +app/*/__pycache__/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..58dfc78 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +RUN addgroup --system app && adduser --system --ingroup app app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app +COPY README.md ./ + +RUN chown -R app:app /app +USER app + +EXPOSE 8000 + +CMD ["sh", "-c", "if [ -n \"$SHOP_ACCOUNT_PASSWORD\" ]; then python -m app.db.create_shop_account; fi; exec uvicorn app.main:app --host 0.0.0.0 --port 8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..6bf24db --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# Secure E-commerce API MVP + +FastAPI + SQLite MVP for a retail order-payment flow with modular architecture, JWT authentication, RBAC (client/shop roles), object-level authorization, strict validation, and secure logging. + +The project also includes a browser UI at `/` for the complete client flow: registration, login, catalog browsing, cart checkout, order viewing, mock payment confirmation, and shop-only product creation. + +## Features + +- Customer registration and login with short-lived JWT access tokens +- Product catalog browsing +- Shop-only product creation +- Order creation from cart-style line items +- Object-level authorization for viewing and paying only your own orders +- Mock payment confirmation that updates order status +- Secure audit logging that avoids passwords, JWTs, and sensitive identifiers +- Static frontend served by FastAPI with same-origin API calls + +## Roles + +- **`client`** — default role, assigned on registration. Can browse products, create orders, view own orders, make payments. +- **`shop`** — privileged role. Can create products. Assigned to the default shop account. + +## Project Structure + +```text +app/ + api/ + core/ + db/ + models/ + schemas/ + services/ + main.py +``` + +## Requirements + +- Python 3.12 recommended + +## Setup + +1. Create and activate a virtual environment. +2. Install dependencies: + +```bash +pip install -r requirements.txt +``` + +3. Optional: create a local environment file from the example and adjust secrets: + +```bash +cp .env.example .env +``` + +## Initialize the SQLite Database + +The application creates tables automatically on startup. You can also initialize the database explicitly: + +```bash +python -m app.db.init_db +``` + +### Create a Shop Account + +A shop account is **not** created by default. Run the dedicated script to create one: + +```bash +python -m app.db.create_shop_account +``` + +For safer bootstrap, the script no longer prints generated credentials to stdout. +Use one of these approaches: + +1. Interactive mode: the script securely asks for a strong password via `getpass()`. +2. Non-interactive mode: set `SHOP_ACCOUNT_PASSWORD` before running the script. + +Example output: + +``` +============================================================ +SHOP ACCOUNT CREATED SUCCESSFULLY +============================================================ +Username: shop +============================================================ +Password was accepted and hashed without being printed to stdout. +============================================================ +``` + +## Run the Server + +```bash +uvicorn app.main:app --reload +``` + +Open: + +- Frontend: `http://127.0.0.1:8000/` +- API docs: `http://127.0.0.1:8000/docs` +- Health check: `http://127.0.0.1:8000/health` + +## Docker + +```bash +docker build -t secure-online-shop . +docker run -d --name secure-online-shop \ + -p 80:8000 \ + -e JWT_SECRET_KEY="replace-with-a-strong-32-plus-char-random-secret-value" \ + -e SHOP_ACCOUNT_PASSWORD="StrongShopPassword1!" \ + -e DEMO_SEED_PRODUCTS=true \ + secure-online-shop +``` + +`SHOP_ACCOUNT_PASSWORD` is optional, but setting it creates the `shop` account at container startup. `DEMO_SEED_PRODUCTS=true` fills the catalog with demo rows for MVP presentation. + +## Example Flow + +1. Register a client with `POST /api/v1/auth/register` +2. Log in with `POST /api/v1/auth/login` +3. Log in as shop and create products with `POST /api/v1/products` +4. Browse products with `GET /api/v1/products` +5. Create an order with `POST /api/v1/orders` +6. View your orders with `GET /api/v1/orders` +7. Confirm payment with `POST /api/v1/payments/orders/{order_id}/confirm` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..18b665e --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +"""Application package.""" diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..dff53e5 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1 @@ +"""API package.""" diff --git a/app/api/auth.py b/app/api/auth.py new file mode 100644 index 0000000..419a334 --- /dev/null +++ b/app/api/auth.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, Request, status +from sqlalchemy.orm import Session + +from app.api.dependencies import get_current_user +from app.core.exceptions import UnauthorizedError +from app.core.rate_limit import login_rate_limiter +from app.db.session import get_db +from app.models.user import User +from app.schemas.auth import LoginRequest, TokenResponse, UserCreate, UserResponse +from app.services.auth_service import AuthService + +router = APIRouter() + + +@router.post( + "/register", + response_model=UserResponse, + status_code=status.HTTP_201_CREATED, + summary="Register a customer account", +) +def register_user( + payload: UserCreate, + db: Annotated[Session, Depends(get_db)], +) -> UserResponse: + user = AuthService(db).register_user(payload) + return UserResponse.model_validate(user) + + +@router.post( + "/login", + response_model=TokenResponse, + status_code=status.HTTP_200_OK, + summary="Authenticate and receive a JWT access token", +) +def login( + payload: LoginRequest, + request: Request, + db: Annotated[Session, Depends(get_db)], +) -> TokenResponse: + client_host = request.client.host if request.client else "unknown" + throttle_key = login_rate_limiter.build_key(payload.username, client_host) + + if login_rate_limiter.is_limited(throttle_key): + login_rate_limiter.raise_limit_exceeded() + + try: + access_token = AuthService(db).authenticate_user(payload) + except UnauthorizedError: + login_rate_limiter.record_failure(throttle_key) + raise + + login_rate_limiter.reset(throttle_key) + return TokenResponse(access_token=access_token) + + +@router.get( + "/me", + response_model=UserResponse, + status_code=status.HTTP_200_OK, + summary="Get the authenticated user profile", +) +def get_me( + current_user: Annotated[User, Depends(get_current_user)], +) -> UserResponse: + return UserResponse.model_validate(current_user) diff --git a/app/api/dependencies.py b/app/api/dependencies.py new file mode 100644 index 0000000..149eb7e --- /dev/null +++ b/app/api/dependencies.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import Depends +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.orm import Session + +from app.core.exceptions import ForbiddenError, UnauthorizedError +from app.core.security import decode_access_token +from app.db.session import get_db +from app.models.user import RoleEnum, User +from app.services.auth_service import AuthService + +bearer_scheme = HTTPBearer( + scheme_name="BearerAuth", + description="JWT token from /api/v1/auth/login", +) + + +def get_current_user( + db: Annotated[Session, Depends(get_db)], + credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer_scheme)], +) -> User: + token_payload = decode_access_token(credentials.credentials) + user = AuthService(db).get_user_by_id(token_payload.user_id) + if user is None or not user.is_active: + raise UnauthorizedError("Could not validate credentials") + return user + + +def get_current_shop( + current_user: Annotated[User, Depends(get_current_user)], +) -> User: + if current_user.role != RoleEnum.shop: + raise ForbiddenError("Shop privileges are required") + return current_user diff --git a/app/api/orders.py b/app/api/orders.py new file mode 100644 index 0000000..bdc3884 --- /dev/null +++ b/app/api/orders.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, status +from sqlalchemy.orm import Session + +from app.api.dependencies import get_current_user +from app.db.session import get_db +from app.models.user import User +from app.schemas.order import OrderCreate, OrderResponse +from app.services.order_service import OrderService + +router = APIRouter() + + +@router.post( + "", + response_model=OrderResponse, + status_code=status.HTTP_201_CREATED, + summary="Create an order from cart items", +) +def create_order( + payload: OrderCreate, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +) -> OrderResponse: + order = OrderService(db).create_order(current_user, payload) + return OrderResponse.model_validate(order) + + +@router.get( + "", + response_model=list[OrderResponse], + status_code=status.HTTP_200_OK, + summary="View the authenticated user's orders", +) +def list_my_orders( + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +) -> list[OrderResponse]: + orders = OrderService(db).get_orders_for_user(current_user) + return [OrderResponse.model_validate(order) for order in orders] + + +@router.get( + "/{order_id}", + response_model=OrderResponse, + status_code=status.HTTP_200_OK, + summary="View a specific order owned by the authenticated user", +) +def get_my_order( + order_id: int, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +) -> OrderResponse: + order = OrderService(db).get_order_for_user(order_id, current_user) + return OrderResponse.model_validate(order) diff --git a/app/api/payments.py b/app/api/payments.py new file mode 100644 index 0000000..6191848 --- /dev/null +++ b/app/api/payments.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, status +from sqlalchemy.orm import Session + +from app.api.dependencies import get_current_user +from app.db.session import get_db +from app.models.user import User +from app.schemas.payment import PaymentResponse +from app.services.payment_service import PaymentService + +router = APIRouter() + + +@router.post( + "/orders/{order_id}/confirm", + response_model=PaymentResponse, + status_code=status.HTTP_200_OK, + summary="Mock payment confirmation for an owned order", +) +def confirm_order_payment( + order_id: int, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +) -> PaymentResponse: + order = PaymentService(db).confirm_payment(order_id, current_user) + return PaymentResponse.model_validate(order) diff --git a/app/api/products.py b/app/api/products.py new file mode 100644 index 0000000..6ec40de --- /dev/null +++ b/app/api/products.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, status +from sqlalchemy.orm import Session + +from app.api.dependencies import get_current_shop +from app.db.session import get_db +from app.models.user import User +from app.schemas.product import ProductCreate, ProductResponse +from app.services.product_service import ProductService + +router = APIRouter() + + +@router.get( + "", + response_model=list[ProductResponse], + status_code=status.HTTP_200_OK, + summary="Browse available products", +) +def list_products( + db: Annotated[Session, Depends(get_db)], +) -> list[ProductResponse]: + products = ProductService(db).list_available_products() + return [ProductResponse.model_validate(product) for product in products] + + +@router.post( + "", + response_model=ProductResponse, + status_code=status.HTTP_201_CREATED, + summary="Create a product (shop only)", +) +def create_product( + payload: ProductCreate, + db: Annotated[Session, Depends(get_db)], + current_shop: Annotated[User, Depends(get_current_shop)], +) -> ProductResponse: + product = ProductService(db).create_product(payload, current_shop) + return ProductResponse.model_validate(product) diff --git a/app/api/router.py b/app/api/router.py new file mode 100644 index 0000000..bad03c0 --- /dev/null +++ b/app/api/router.py @@ -0,0 +1,9 @@ +from fastapi import APIRouter + +from app.api import auth, orders, payments, products + +api_router = APIRouter() +api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) +api_router.include_router(products.router, prefix="/products", tags=["catalog"]) +api_router.include_router(orders.router, prefix="/orders", tags=["orders"]) +api_router.include_router(payments.router, prefix="/payments", tags=["payments"]) diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..5ec3177 --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1 @@ +"""Core application utilities.""" diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..33cf5f4 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path + +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +PROJECT_ROOT = Path(__file__).resolve().parents[2] + + +class Settings(BaseSettings): + app_name: str = "Secure E-commerce API" + api_v1_prefix: str = "/api/v1" + database_url: str = "sqlite:///./ecommerce.db" + jwt_secret_key: str = Field(min_length=32) + jwt_algorithm: str = "HS256" + jwt_issuer: str = "secure-ecommerce-api" + jwt_audience: str = "secure-ecommerce-clients" + access_token_expire_minutes: int = Field(default=30, ge=5, le=120) + auth_rate_limit_attempts: int = Field(default=5, ge=3, le=20) + auth_rate_limit_window_seconds: int = Field(default=300, ge=60, le=3600) + auth_rate_limit_max_buckets: int = Field(default=5000, ge=100, le=100_000) + log_level: str = "INFO" + demo_seed_products: bool = False + demo_product_count: int = Field(default=220, ge=0, le=1000) + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + @field_validator("jwt_secret_key") + @classmethod + def validate_jwt_secret_key(cls, value: str) -> str: + insecure_values = { + "change-this-secret-in-production", + "replace-with-a-long-random-secret", + } + if value in insecure_values: + raise ValueError("JWT_SECRET_KEY must be replaced with a strong random secret") + return value + + @field_validator("database_url") + @classmethod + def validate_database_url(cls, value: str) -> str: + sqlite_prefix = "sqlite:///" + if not value.startswith(sqlite_prefix): + return value + + raw_path = value[len(sqlite_prefix):] + if raw_path == ":memory:": + return value + if ( + len(raw_path) >= 3 + and raw_path[1] == ":" + and raw_path[0].isalpha() + and raw_path[2] in {"/", "\\"} + ): + raise ValueError( + "SQLite database file must stay inside the project directory" + ) + + candidate = Path(raw_path) + resolved = candidate.resolve() if candidate.is_absolute() else (PROJECT_ROOT / candidate).resolve() + try: + resolved.relative_to(PROJECT_ROOT) + except ValueError as exc: + raise ValueError("SQLite database file must stay inside the project directory") from exc + + normalized_relative_path = resolved.relative_to(PROJECT_ROOT).as_posix() + return f"{sqlite_prefix}./{normalized_relative_path}" + + +@lru_cache +def get_settings() -> Settings: + return Settings() # type: ignore[call-arg] diff --git a/app/core/exceptions.py b/app/core/exceptions.py new file mode 100644 index 0000000..3a54115 --- /dev/null +++ b/app/core/exceptions.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import logging + +from fastapi import FastAPI, HTTPException, Request, status +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from sqlalchemy.exc import SQLAlchemyError + +logger = logging.getLogger("app.errors") + + +class AppError(Exception): + def __init__(self, status_code: int, detail: str) -> None: + self.status_code = status_code + self.detail = detail + super().__init__(detail) + + +class BadRequestError(AppError): + def __init__(self, detail: str = "Bad request") -> None: + super().__init__(status.HTTP_400_BAD_REQUEST, detail) + + +class UnauthorizedError(AppError): + def __init__(self, detail: str = "Authentication failed") -> None: + super().__init__(status.HTTP_401_UNAUTHORIZED, detail) + + +class ForbiddenError(AppError): + def __init__(self, detail: str = "Access denied") -> None: + super().__init__(status.HTTP_403_FORBIDDEN, detail) + + +class NotFoundError(AppError): + def __init__(self, detail: str = "Resource not found") -> None: + super().__init__(status.HTTP_404_NOT_FOUND, detail) + + +class ConflictError(AppError): + def __init__(self, detail: str = "Conflict detected") -> None: + super().__init__(status.HTTP_409_CONFLICT, detail) + + +class TooManyRequestsError(AppError): + def __init__(self, detail: str = "Too many requests") -> None: + super().__init__(status.HTTP_429_TOO_MANY_REQUESTS, detail) + + +def register_exception_handlers(app: FastAPI) -> None: + @app.exception_handler(AppError) + async def handle_app_error(_: Request, exc: AppError) -> JSONResponse: + return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) + + @app.exception_handler(HTTPException) + async def handle_http_exception(_: Request, exc: HTTPException) -> JSONResponse: + return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) + + @app.exception_handler(RequestValidationError) + async def handle_validation_error( + _: Request, exc: RequestValidationError + ) -> JSONResponse: + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content={"detail": "Validation error"}, + ) + + @app.exception_handler(SQLAlchemyError) + async def handle_database_error(_: Request, exc: SQLAlchemyError) -> JSONResponse: + logger.exception("Database operation failed") + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"detail": "Internal server error"}, + ) + + @app.exception_handler(Exception) + async def handle_unexpected_error(_: Request, exc: Exception) -> JSONResponse: + logger.exception("Unhandled application error") + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"detail": "Internal server error"}, + ) diff --git a/app/core/logging.py b/app/core/logging.py new file mode 100644 index 0000000..c7b0cc7 --- /dev/null +++ b/app/core/logging.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import logging +import logging.config +import re +from collections.abc import Mapping, Sequence +from typing import Any + +SENSITIVE_KEYS = { + "password", + "password_hash", + "token", + "access_token", + "refresh_token", + "authorization", + "email", +} +TOKEN_PATTERN = re.compile(r"Bearer\s+[A-Za-z0-9\-._~+/]+=*", re.IGNORECASE) + + +def _sanitize_value(value: Any) -> Any: + if isinstance(value, str): + redacted = TOKEN_PATTERN.sub("Bearer [REDACTED]", value) + for key in SENSITIVE_KEYS: + redacted = re.sub( + rf"({key}\s*=\s*)([^,\s]+)", + r"\1[REDACTED]", + redacted, + flags=re.IGNORECASE, + ) + return redacted + + if isinstance(value, Mapping): + return { + key: "[REDACTED]" if str(key).lower() in SENSITIVE_KEYS else _sanitize_value(item) + for key, item in value.items() + } + + if isinstance(value, tuple): + return tuple(_sanitize_value(item) for item in value) + + if isinstance(value, list): + return [_sanitize_value(item) for item in value] + + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return [_sanitize_value(item) for item in value] + + return value + + +class SensitiveDataFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + record.msg = _sanitize_value(record.msg) + if record.args: + record.args = _sanitize_value(record.args) + return True + + +def setup_logging(log_level: str) -> None: + logging.config.dictConfig( + { + "version": 1, + "disable_existing_loggers": False, + "filters": { + "sensitive_data_filter": { + "()": "app.core.logging.SensitiveDataFilter", + } + }, + "formatters": { + "standard": { + "format": "%(asctime)s %(levelname)s [%(name)s] %(message)s", + } + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "filters": ["sensitive_data_filter"], + "formatter": "standard", + } + }, + "root": { + "level": log_level.upper(), + "handlers": ["console"], + }, + } + ) diff --git a/app/core/rate_limit.py b/app/core/rate_limit.py new file mode 100644 index 0000000..2030ca5 --- /dev/null +++ b/app/core/rate_limit.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import hashlib +from collections import deque +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from threading import Lock + +from app.core.config import get_settings +from app.core.exceptions import TooManyRequestsError + +settings = get_settings() + + +@dataclass(slots=True) +class _AttemptBucket: + timestamps: deque[datetime] = field(default_factory=deque) + last_seen: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + +class LoginRateLimiter: + def __init__(self, attempts: int, window_seconds: int, max_buckets: int) -> None: + self.attempts = attempts + self.window = timedelta(seconds=window_seconds) + self.max_buckets = max_buckets + self._lock = Lock() + self._buckets: dict[str, _AttemptBucket] = {} + + def build_key(self, username: str, client_host: str) -> str: + material = f"{username.lower()}:{client_host}".encode("utf-8") + return hashlib.sha256(material).hexdigest() + + def is_limited(self, key: str) -> bool: + with self._lock: + now = datetime.now(timezone.utc) + self._cleanup(now=now) + bucket = self._buckets.get(key) + if bucket is None: + return False + self._touch(bucket, now=now) + self._prune(bucket, now=now) + return len(bucket.timestamps) >= self.attempts + + def record_failure(self, key: str) -> None: + with self._lock: + now = datetime.now(timezone.utc) + self._cleanup(now=now) + bucket = self._buckets.get(key) + if bucket is None: + if len(self._buckets) >= self.max_buckets: + self._evict_oldest_bucket() + bucket = _AttemptBucket() + self._buckets[key] = bucket + self._touch(bucket, now=now) + self._prune(bucket, now=now) + bucket.timestamps.append(now) + + def reset(self, key: str) -> None: + with self._lock: + self._buckets.pop(key, None) + + def raise_limit_exceeded(self) -> None: + raise TooManyRequestsError("Too many login attempts. Please try again later.") + + def _prune(self, bucket: _AttemptBucket, *, now: datetime) -> None: + cutoff = now - self.window + while bucket.timestamps and bucket.timestamps[0] < cutoff: + bucket.timestamps.popleft() + + @staticmethod + def _touch(bucket: _AttemptBucket, *, now: datetime) -> None: + bucket.last_seen = now + + def _cleanup(self, *, now: datetime) -> None: + stale_keys: list[str] = [] + for key, bucket in self._buckets.items(): + self._prune(bucket, now=now) + if not bucket.timestamps and bucket.last_seen < now - self.window: + stale_keys.append(key) + for key in stale_keys: + self._buckets.pop(key, None) + + def _evict_oldest_bucket(self) -> None: + oldest_key = min(self._buckets, key=lambda bucket_key: self._buckets[bucket_key].last_seen) + self._buckets.pop(oldest_key, None) + + +login_rate_limiter = LoginRateLimiter( + attempts=settings.auth_rate_limit_attempts, + window_seconds=settings.auth_rate_limit_window_seconds, + max_buckets=settings.auth_rate_limit_max_buckets, +) diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..e3cc806 --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from uuid import uuid4 + +from jose import JWTError, jwt +from passlib.context import CryptContext + +from app.core.config import get_settings +from app.core.exceptions import UnauthorizedError + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +settings = get_settings() +ACCESS_TOKEN_TYPE = "access" # nosec B105 + + +@dataclass(slots=True) +class TokenPayload: + user_id: int + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) + + +def create_access_token(subject: str) -> str: + now = datetime.now(timezone.utc) + expire = now + timedelta(minutes=settings.access_token_expire_minutes) + to_encode = { + "sub": subject, + "iat": int(now.timestamp()), + "nbf": int(now.timestamp()), + "exp": int(expire.timestamp()), + "jti": str(uuid4()), + "type": ACCESS_TOKEN_TYPE, + "iss": settings.jwt_issuer, + "aud": settings.jwt_audience, + } + return jwt.encode(to_encode, settings.jwt_secret_key, algorithm=settings.jwt_algorithm) + + +def decode_access_token(token: str) -> TokenPayload: + try: + payload = jwt.decode( + token, + settings.jwt_secret_key, + algorithms=[settings.jwt_algorithm], + issuer=settings.jwt_issuer, + audience=settings.jwt_audience, + ) + subject = payload.get("sub") + token_type = payload.get("type") + if subject is None or token_type != ACCESS_TOKEN_TYPE: + raise UnauthorizedError("Could not validate credentials") + return TokenPayload(user_id=int(subject)) + except (JWTError, ValueError) as exc: + raise UnauthorizedError("Could not validate credentials") from exc diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..cdce083 --- /dev/null +++ b/app/db/__init__.py @@ -0,0 +1 @@ +"""Database package.""" diff --git a/app/db/create_shop_account.py b/app/db/create_shop_account.py new file mode 100644 index 0000000..044bb7f --- /dev/null +++ b/app/db/create_shop_account.py @@ -0,0 +1,101 @@ +""" +Script to create a shop account with an operator-supplied strong password. + +Usage: + python -m app.db.create_shop_account + +Provide SHOP_ACCOUNT_PASSWORD for non-interactive execution, +or enter the password securely via getpass(). +""" + +from __future__ import annotations + +import os +import sys +from getpass import getpass + +from sqlalchemy import select + +from app.core.security import get_password_hash +from app.db.session import SessionLocal, engine +from app.models import Base, RoleEnum, User + +SHOP_USERNAME = "shop" +MIN_PASSWORD_LENGTH = 12 + + +def _validate_password_strength(password: str) -> None: + if len(password) < MIN_PASSWORD_LENGTH or len(password) > 72: + raise ValueError("Password must be between 12 and 72 characters long.") + has_upper = any(char.isupper() for char in password) + has_lower = any(char.islower() for char in password) + has_digit = any(char.isdigit() for char in password) + has_special = any(not char.isalnum() for char in password) + if not all((has_upper, has_lower, has_digit, has_special)): + raise ValueError( + "Password must include upper, lower, digit, and special characters." + ) + + +def _get_shop_password() -> str: + env_password = os.getenv("SHOP_ACCOUNT_PASSWORD", "").strip() + if env_password: + _validate_password_strength(env_password) + return env_password + + if not sys.stdin.isatty(): + raise RuntimeError( + "Interactive password input is unavailable. Set SHOP_ACCOUNT_PASSWORD to create the account safely." + ) + + while True: + password = getpass("Enter a strong password for the shop account: ") + confirm_password = getpass("Confirm the password: ") + if password != confirm_password: + print("Passwords do not match. Please try again.", file=sys.stderr) + continue + _validate_password_strength(password) + return password + + +def create_shop_account() -> None: + """Create a shop account with a strong password if it doesn't exist.""" + Base.metadata.create_all(bind=engine) + + with SessionLocal() as db: + existing = db.scalar( + select(User).where(User.username == SHOP_USERNAME) + ) + if existing is not None: + print(f"Shop account '{SHOP_USERNAME}' already exists (id={existing.id}).") + print("No new account was created.") + return + + password = _get_shop_password() + password_hash = get_password_hash(password) + + shop = User( + username=SHOP_USERNAME, + password_hash=password_hash, + role=RoleEnum.shop, + is_active=True, + ) + db.add(shop) + db.commit() + db.refresh(shop) + + print("=" * 60) + print("SHOP ACCOUNT CREATED SUCCESSFULLY") + print("=" * 60) + print(f"Username: {SHOP_USERNAME}") + print("=" * 60) + print("Password was accepted and hashed without being printed to stdout.") + print("=" * 60) + + +if __name__ == "__main__": + try: + create_shop_account() + except Exception as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) diff --git a/app/db/init_db.py b/app/db/init_db.py new file mode 100644 index 0000000..0f8cc37 --- /dev/null +++ b/app/db/init_db.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import logging + +from app.core.config import get_settings +from app.db.seed_demo import seed_demo_products +from app.db.session import engine +from app.models import Base + +logger = logging.getLogger("app.db") + + +def initialize_database() -> None: + Base.metadata.create_all(bind=engine) + logger.info("Database tables created successfully") + settings = get_settings() + if settings.demo_seed_products: + from app.db.session import SessionLocal + + with SessionLocal() as db: + seed_demo_products(db, settings.demo_product_count) + + +if __name__ == "__main__": + initialize_database() + print("Database initialized successfully.") + print("To create a shop account, run: python -m app.db.create_shop_account") diff --git a/app/db/seed_demo.py b/app/db/seed_demo.py new file mode 100644 index 0000000..60d0773 --- /dev/null +++ b/app/db/seed_demo.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import logging +from decimal import Decimal + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.models.product import Product + +logger = logging.getLogger("app.db") + +PRODUCT_TEMPLATES = ( + ("Aurora Headphones", "Wireless headphones with active noise reduction", "189.90", 42), + ("Nimbus Laptop", "Thin laptop for study, work, and secure online payments", "1299.00", 18), + ("Pulse Smartwatch", "Fitness smartwatch with long battery life", "249.50", 55), + ("Volt Power Bank", "Compact 20000 mAh power bank with fast charging", "79.99", 90), + ("Axis Keyboard", "Mechanical keyboard with quiet tactile switches", "139.00", 36), + ("Orbit Backpack", "Water-resistant backpack with laptop compartment", "89.90", 64), + ("Brew Coffee Maker", "Programmable drip coffee maker for home offices", "119.95", 31), + ("Frame Desk Lamp", "Adjustable LED lamp with warm and cold light modes", "54.40", 83), + ("Focus Webcam", "Full HD webcam with privacy shutter", "74.99", 71), + ("Studio Speaker", "Bluetooth speaker with balanced stereo sound", "159.00", 27), + ("Terra Sneakers", "Lightweight everyday sneakers with cushioned soles", "109.90", 49), + ("Slate Tablet", "Portable tablet for browsing, media, and field work", "399.00", 22), +) + + +def seed_demo_products(db: Session, target_count: int) -> None: + """Populate the catalog with deterministic demo products for MVP deployment.""" + if target_count <= 0: + return + + existing_count = db.scalar(select(func.count(Product.id))) or 0 + products_to_create = target_count - existing_count + if products_to_create <= 0: + logger.info("Demo product seed skipped existing_count=%s", existing_count) + return + + products: list[Product] = [] + for offset in range(products_to_create): + sequence = existing_count + offset + 1 + name, description, price, base_stock = PRODUCT_TEMPLATES[offset % len(PRODUCT_TEMPLATES)] + batch = (offset // len(PRODUCT_TEMPLATES)) + 1 + products.append( + Product( + name=f"{name} {batch:02d}", + description=description, + price=Decimal(price), + stock=base_stock + (sequence % 17), + is_active=True, + ) + ) + + db.add_all(products) + db.commit() + logger.info( + "Demo product seed completed created=%s target_count=%s", + len(products), + target_count, + ) diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..81be4ee --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.core.config import get_settings + +settings = get_settings() + +sqlite_connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {} + +engine = create_engine( + settings.database_url, + connect_args=sqlite_connect_args, + pool_pre_ping=True, +) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False) + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..3a10854 --- /dev/null +++ b/app/main.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI, status +from fastapi.openapi.utils import get_openapi +from fastapi.responses import FileResponse +from fastapi.security import HTTPBearer +from fastapi.staticfiles import StaticFiles + +from app.api.router import api_router +from app.core.config import get_settings +from app.core.exceptions import register_exception_handlers +from app.core.logging import setup_logging +from app.db.init_db import initialize_database + +settings = get_settings() +setup_logging(settings.log_level) +STATIC_DIR = Path(__file__).resolve().parent / "static" + +bearer_scheme = HTTPBearer() + + +@asynccontextmanager +async def lifespan(_: FastAPI): + initialize_database() + yield + + +app = FastAPI( + title=settings.app_name, + version="1.0.0", + lifespan=lifespan, + swagger_ui_parameters={}, +) +app.openapi_tags = [ + {"name": "auth", "description": "Регистрация и вход в систему"}, + {"name": "catalog", "description": "Каталог товаров"}, + {"name": "orders", "description": "Управление заказами"}, + {"name": "payments", "description": "Оплата заказов"}, +] +security_schemes = { + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "Введите JWT токен, получен через POST /api/v1/auth/login", + }, +} +app.openapi_schema = None # Force regeneration + +register_exception_handlers(app) +app.include_router(api_router, prefix=settings.api_v1_prefix) +app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + + +@app.get("/health", status_code=status.HTTP_200_OK, summary="Health check") +def health_check() -> dict[str, str]: + return {"status": "ok"} + + +@app.get("/", include_in_schema=False) +def frontend_index() -> FileResponse: + return FileResponse(STATIC_DIR / "index.html") + + +def custom_openapi(): + if app.openapi_schema: + return app.openapi_schema + openapi_schema = get_openapi( + title=app.title, + version=app.version, + routes=app.routes, + ) + openapi_schema["components"]["securitySchemes"] = security_schemes + app.openapi_schema = openapi_schema + return app.openapi_schema + +app.openapi = custom_openapi # type: ignore[method-assign] diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..ab1613a --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,15 @@ +from app.models.base import Base +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 RoleEnum, User + +__all__ = [ + "Base", + "Order", + "OrderItem", + "OrderStatusEnum", + "Product", + "RoleEnum", + "User", +] diff --git a/app/models/base.py b/app/models/base.py new file mode 100644 index 0000000..fa2b68a --- /dev/null +++ b/app/models/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/app/models/order.py b/app/models/order.py new file mode 100644 index 0000000..3da6097 --- /dev/null +++ b/app/models/order.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from decimal import Decimal +from enum import Enum + +from sqlalchemy import CheckConstraint, DateTime, Enum as SqlEnum, ForeignKey, Numeric +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base + + +class OrderStatusEnum(str, Enum): + pending = "pending" + paid = "paid" + + +class Order(Base): + __tablename__ = "orders" + __table_args__ = ( + CheckConstraint("total_amount >= 0", name="ck_orders_total_amount_non_negative"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True) + total_amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False) + status: Mapped[OrderStatusEnum] = mapped_column( + SqlEnum(OrderStatusEnum, native_enum=False), + default=OrderStatusEnum.pending, + nullable=False, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + nullable=False, + ) + paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + user = relationship("User", back_populates="orders") + items = relationship( + "OrderItem", + back_populates="order", + cascade="all, delete-orphan", + lazy="selectin", + ) diff --git a/app/models/order_item.py b/app/models/order_item.py new file mode 100644 index 0000000..5124882 --- /dev/null +++ b/app/models/order_item.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from decimal import Decimal + +from sqlalchemy import CheckConstraint, ForeignKey, Numeric +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base + + +class OrderItem(Base): + __tablename__ = "order_items" + __table_args__ = ( + CheckConstraint("quantity > 0", name="ck_order_items_quantity_positive"), + CheckConstraint("unit_price > 0", name="ck_order_items_unit_price_positive"), + CheckConstraint("subtotal >= 0", name="ck_order_items_subtotal_non_negative"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), nullable=False, index=True) + product_id: Mapped[int] = mapped_column(ForeignKey("products.id"), nullable=False, index=True) + quantity: Mapped[int] = mapped_column(nullable=False) + unit_price: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False) + subtotal: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False) + + order = relationship("Order", back_populates="items") + product = relationship("Product", lazy="joined") + + @property + def product_name(self) -> str: + return self.product.name diff --git a/app/models/product.py b/app/models/product.py new file mode 100644 index 0000000..e1861a6 --- /dev/null +++ b/app/models/product.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from decimal import Decimal + +from sqlalchemy import Boolean, CheckConstraint, DateTime, Numeric, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class Product(Base): + __tablename__ = "products" + __table_args__ = ( + CheckConstraint("price > 0", name="ck_products_price_positive"), + CheckConstraint("stock >= 0", name="ck_products_stock_non_negative"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(120), nullable=False, index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + price: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False) + stock: Mapped[int] = mapped_column(nullable=False, default=0) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + nullable=False, + ) diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..f0a29f2 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from enum import Enum + +from sqlalchemy import Boolean, DateTime, Enum as SqlEnum, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base + + +class RoleEnum(str, Enum): + client = "client" + shop = "shop" + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True) + username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + role: Mapped[RoleEnum] = mapped_column( + SqlEnum(RoleEnum, native_enum=False), + nullable=False, + default=RoleEnum.client, + ) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + nullable=False, + ) + + orders = relationship("Order", back_populates="user", lazy="selectin") diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..e964283 --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1 @@ +"""Pydantic schemas package.""" diff --git a/app/schemas/auth.py b/app/schemas/auth.py new file mode 100644 index 0000000..132a959 --- /dev/null +++ b/app/schemas/auth.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Annotated, Literal + +from pydantic import BaseModel, ConfigDict, StrictStr, StringConstraints, field_validator + +from app.models.user import RoleEnum + +UsernameField = Annotated[ + StrictStr, + StringConstraints( + strip_whitespace=True, + min_length=3, + max_length=50, + ), +] +PasswordField = Annotated[ + StrictStr, + StringConstraints( + min_length=8, + max_length=72, + ), +] +StrongPasswordField = Annotated[ + StrictStr, + StringConstraints( + min_length=12, + max_length=72, + ), +] + + +class UserCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + + username: UsernameField + password: StrongPasswordField + + @field_validator("username") + @classmethod + def normalize_username(cls, value: str) -> str: + return value.lower() + + @field_validator("password") + @classmethod + def validate_password_strength(cls, value: str) -> str: + has_upper = any(char.isupper() for char in value) + has_lower = any(char.islower() for char in value) + has_digit = any(char.isdigit() for char in value) + has_special = any(not char.isalnum() for char in value) + if not all((has_upper, has_lower, has_digit, has_special)): + raise ValueError( + "Password must include upper, lower, digit, and special characters" + ) + return value + + +class LoginRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + username: UsernameField + password: PasswordField + + @field_validator("username") + @classmethod + def normalize_username(cls, value: str) -> str: + return value.lower() + + +class UserResponse(BaseModel): + model_config = ConfigDict(from_attributes=True, extra="forbid") + + id: int + username: str + role: RoleEnum + is_active: bool + created_at: datetime + + +class TokenResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + + access_token: str + token_type: Literal["bearer"] = "bearer" diff --git a/app/schemas/order.py b/app/schemas/order.py new file mode 100644 index 0000000..bf1e60c --- /dev/null +++ b/app/schemas/order.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from typing import Annotated + +from pydantic import BaseModel, ConfigDict, Field, StrictInt, model_validator + +from app.models.order import OrderStatusEnum + + +class OrderCreateItem(BaseModel): + model_config = ConfigDict(extra="forbid") + + product_id: Annotated[StrictInt, Field(gt=0)] + quantity: Annotated[StrictInt, Field(gt=0, le=1000)] + + +class OrderCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + + items: Annotated[list[OrderCreateItem], Field(min_length=1, max_length=100)] + + @model_validator(mode="after") + def validate_unique_products(self) -> "OrderCreate": + product_ids = [item.product_id for item in self.items] + if len(product_ids) != len(set(product_ids)): + raise ValueError("Each product may only appear once per order") + return self + + +class OrderItemResponse(BaseModel): + model_config = ConfigDict(from_attributes=True, extra="forbid") + + id: int + product_id: int + product_name: str + quantity: int + unit_price: Decimal + subtotal: Decimal + + +class OrderResponse(BaseModel): + model_config = ConfigDict(from_attributes=True, extra="forbid") + + id: int + status: OrderStatusEnum + total_amount: Decimal + created_at: datetime + paid_at: datetime | None + items: list[OrderItemResponse] diff --git a/app/schemas/payment.py b/app/schemas/payment.py new file mode 100644 index 0000000..69ba486 --- /dev/null +++ b/app/schemas/payment.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict + +from app.models.order import OrderStatusEnum + + +class PaymentResponse(BaseModel): + model_config = ConfigDict(from_attributes=True, extra="forbid") + + id: int + status: OrderStatusEnum + total_amount: Decimal + paid_at: datetime | None diff --git a/app/schemas/product.py b/app/schemas/product.py new file mode 100644 index 0000000..2ec7861 --- /dev/null +++ b/app/schemas/product.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from typing import Annotated + +from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, StrictStr, StringConstraints + +NameField = Annotated[ + StrictStr, + StringConstraints(strip_whitespace=True, min_length=3, max_length=120), +] +DescriptionField = Annotated[ + StrictStr, + StringConstraints(strip_whitespace=True, min_length=1, max_length=1000), +] + + +class ProductCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: NameField + description: DescriptionField | None = None + price: Annotated[Decimal, Field(gt=0, max_digits=10, decimal_places=2)] + stock: Annotated[StrictInt, Field(ge=0, le=1_000_000)] + is_active: StrictBool = True + + +class ProductResponse(BaseModel): + model_config = ConfigDict(from_attributes=True, extra="forbid") + + id: int + name: str + description: str | None + price: Decimal + stock: int + is_active: bool + created_at: datetime diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..f4f478c --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +"""Service layer package.""" diff --git a/app/services/auth_service.py b/app/services/auth_service.py new file mode 100644 index 0000000..c585b04 --- /dev/null +++ b/app/services/auth_service.py @@ -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] diff --git a/app/services/order_service.py b/app/services/order_service.py new file mode 100644 index 0000000..d29f586 --- /dev/null +++ b/app/services/order_service.py @@ -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 diff --git a/app/services/payment_service.py b/app/services/payment_service.py new file mode 100644 index 0000000..0c2d52b --- /dev/null +++ b/app/services/payment_service.py @@ -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) diff --git a/app/services/product_service.py b/app/services/product_service.py new file mode 100644 index 0000000..ad60521 --- /dev/null +++ b/app/services/product_service.py @@ -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 diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..e32c1b1 --- /dev/null +++ b/app/static/app.js @@ -0,0 +1,630 @@ +const API_BASE = "/api/v1"; +const TOKEN_KEY = "secureshop_token"; +const CART_KEY = "secureshop_cart"; + +const artwork = [ + "https://images.unsplash.com/photo-1505740420928-5e560c06d30e?auto=format&fit=crop&w=640&q=80", + "https://images.unsplash.com/photo-1517336714731-489689fd1ca8?auto=format&fit=crop&w=640&q=80", + "https://images.unsplash.com/photo-1523275335684-37898b6baf30?auto=format&fit=crop&w=640&q=80", + "https://images.unsplash.com/photo-1609091839311-d5365f9ff1c5?auto=format&fit=crop&w=640&q=80", + "https://images.unsplash.com/photo-1587829741301-dc798b83add3?auto=format&fit=crop&w=640&q=80", + "https://images.unsplash.com/photo-1553062407-98eeb64c6a62?auto=format&fit=crop&w=640&q=80", + "https://images.unsplash.com/photo-1517668808822-9ebb02f2a0e6?auto=format&fit=crop&w=640&q=80", + "https://images.unsplash.com/photo-1507473885765-e6ed057f782c?auto=format&fit=crop&w=640&q=80", + "https://images.unsplash.com/photo-1587614295999-6c1c1367514e?auto=format&fit=crop&w=640&q=80", + "https://images.unsplash.com/photo-1545454675-3531b543be5d?auto=format&fit=crop&w=640&q=80", + "https://images.unsplash.com/photo-1542291026-7eec264c27ff?auto=format&fit=crop&w=640&q=80", + "https://images.unsplash.com/photo-1544244015-0df4b3ffc6b0?auto=format&fit=crop&w=640&q=80", +]; + +const state = { + activeView: "catalog", + authMode: "login", + token: localStorage.getItem(TOKEN_KEY), + user: null, + products: [], + orders: [], + cart: loadCart(), + search: "", + sort: "new", +}; + +const elements = { + tabs: document.querySelectorAll(".tab"), + viewTitle: document.querySelector("#viewTitle"), + sessionPill: document.querySelector("#sessionPill"), + catalogMetric: document.querySelector("#catalogMetric"), + cartMetric: document.querySelector("#cartMetric"), + healthMetric: document.querySelector("#healthMetric"), + searchInput: document.querySelector("#searchInput"), + sortSelect: document.querySelector("#sortSelect"), + productGrid: document.querySelector("#productGrid"), + ordersList: document.querySelector("#ordersList"), + productForm: document.querySelector("#productForm"), + shopGate: document.querySelector("#shopGate"), + authPanel: document.querySelector("#authPanel"), + cartList: document.querySelector("#cartList"), + cartTotal: document.querySelector("#cartTotal"), + checkoutButton: document.querySelector("#checkoutButton"), + clearCartButton: document.querySelector("#clearCartButton"), + toast: document.querySelector("#toast"), +}; + +function loadCart() { + try { + const parsed = JSON.parse(localStorage.getItem(CART_KEY) || "{}"); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } +} + +function saveCart() { + localStorage.setItem(CART_KEY, JSON.stringify(state.cart)); +} + +function money(value) { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(Number(value || 0)); +} + +function dateTime(value) { + return new Intl.DateTimeFormat("ru-RU", { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(value)); +} + +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function showToast(message) { + elements.toast.textContent = message; + elements.toast.classList.add("is-visible"); + window.clearTimeout(showToast.timeoutId); + showToast.timeoutId = window.setTimeout(() => { + elements.toast.classList.remove("is-visible"); + }, 3600); +} + +async function apiRequest(path, options = {}) { + const headers = { + Accept: "application/json", + ...(options.headers || {}), + }; + if (state.token) { + headers.Authorization = `Bearer ${state.token}`; + } + if (options.body && !headers["Content-Type"]) { + headers["Content-Type"] = "application/json"; + } + + const response = await fetch(`${API_BASE}${path}`, { + ...options, + headers, + }); + const raw = await response.text(); + const data = raw ? JSON.parse(raw) : null; + if (!response.ok) { + throw new Error(data?.detail || "Запрос не выполнен"); + } + return data; +} + +async function checkHealth() { + try { + const response = await fetch("/health", { headers: { Accept: "application/json" } }); + elements.healthMetric.textContent = response.ok ? "API online" : "API error"; + } catch { + elements.healthMetric.textContent = "API offline"; + } +} + +async function loadProducts() { + state.products = await apiRequest("/products"); + syncCartProducts(); +} + +async function loadOrders() { + if (!state.token) { + state.orders = []; + return; + } + state.orders = await apiRequest("/orders"); +} + +async function loadProfile() { + if (!state.token) { + state.user = null; + return; + } + state.user = await apiRequest("/auth/me"); +} + +function syncCartProducts() { + const productsById = new Map(state.products.map((product) => [String(product.id), product])); + Object.keys(state.cart).forEach((id) => { + const product = productsById.get(id); + if (!product) { + delete state.cart[id]; + return; + } + state.cart[id].product = product; + state.cart[id].quantity = Math.min(state.cart[id].quantity, product.stock); + if (state.cart[id].quantity < 1) { + delete state.cart[id]; + } + }); + saveCart(); +} + +function filteredProducts() { + const query = state.search.trim().toLowerCase(); + const products = state.products.filter((product) => { + const haystack = `${product.name} ${product.description || ""}`.toLowerCase(); + return !query || haystack.includes(query); + }); + + products.sort((a, b) => { + if (state.sort === "price-asc") return Number(a.price) - Number(b.price); + if (state.sort === "price-desc") return Number(b.price) - Number(a.price); + if (state.sort === "stock") return Number(b.stock) - Number(a.stock); + return new Date(b.created_at) - new Date(a.created_at); + }); + return products; +} + +function setView(viewName) { + state.activeView = viewName; + document.querySelectorAll(".view").forEach((view) => view.classList.remove("is-active")); + document.querySelector(`#${viewName}View`).classList.add("is-active"); + elements.tabs.forEach((tab) => { + tab.classList.toggle("is-active", tab.dataset.view === viewName); + }); + + const titles = { + catalog: "Каталог товаров", + orders: "Мои заказы", + shop: "Панель магазина", + }; + elements.viewTitle.textContent = titles[viewName]; + if (viewName === "orders") { + refreshOrders(); + } + render(); +} + +function render() { + renderSession(); + renderMetrics(); + renderCatalog(); + renderAuthPanel(); + renderCart(); + renderOrders(); + renderShopGate(); +} + +function renderSession() { + if (!state.user) { + elements.sessionPill.textContent = "Гость"; + return; + } + elements.sessionPill.textContent = `${state.user.username} · ${state.user.role}`; +} + +function renderMetrics() { + const cartQuantity = Object.values(state.cart).reduce((sum, item) => sum + item.quantity, 0); + elements.catalogMetric.textContent = `${state.products.length} товаров`; + elements.cartMetric.textContent = `${cartQuantity} в корзине`; +} + +function renderCatalog() { + const products = filteredProducts(); + if (!products.length) { + elements.productGrid.innerHTML = `
Товары не найдены
`; + return; + } + + elements.productGrid.innerHTML = products + .map((product) => { + const description = product.description || "Товар доступен для заказа"; + const image = artwork[product.id % artwork.length]; + return ` +
+ ${escapeHtml(product.name)} +
+
+
+

${escapeHtml(product.name)}

+ ${money(product.price)} +
+ ${product.stock} шт. +
+

${escapeHtml(description)}

+
+ + +
+
+
+ `; + }) + .join(""); +} + +function renderAuthPanel() { + if (state.user) { + elements.authPanel.innerHTML = ` +
+

Аккаунт

+ ${escapeHtml(state.user.role)} +
+
+ + +
+ `; + return; + } + + const isRegister = state.authMode === "register"; + elements.authPanel.innerHTML = ` +
+ + +
+
+ + + +
+ `; +} + +function renderCart() { + const items = Object.values(state.cart); + if (!items.length) { + elements.cartList.innerHTML = `
Корзина пуста
`; + elements.cartTotal.textContent = money(0); + elements.checkoutButton.disabled = true; + return; + } + + elements.cartList.innerHTML = items + .map(({ product, quantity }) => ` +
+
+ ${escapeHtml(product.name)} + ${quantity} × ${money(product.price)} +
+
+ + ${quantity} + +
+
+ `) + .join(""); + + const total = items.reduce((sum, item) => sum + Number(item.product.price) * item.quantity, 0); + elements.cartTotal.textContent = money(total); + elements.checkoutButton.disabled = false; +} + +function renderOrders() { + if (!state.user) { + elements.ordersList.innerHTML = `
Войдите, чтобы увидеть свои заказы
`; + return; + } + if (!state.orders.length) { + elements.ordersList.innerHTML = `
Заказов пока нет
`; + return; + } + + elements.ordersList.innerHTML = state.orders + .map((order) => ` +
+
+
+

Заказ #${order.id}

+ ${dateTime(order.created_at)} +
+ ${order.status} +
+
+ ${order.items + .map((item) => ` +
+ ${escapeHtml(item.product_name)} × ${item.quantity} + ${money(item.subtotal)} +
+ `) + .join("")} +
+ +
+ `) + .join(""); +} + +function renderShopGate() { + const isShop = state.user?.role === "shop"; + elements.productForm.style.display = isShop ? "block" : "none"; + elements.shopGate.classList.toggle("is-visible", !isShop); + if (!isShop) { + elements.shopGate.innerHTML = ` +
+ ${state.user ? "Доступ к созданию товаров открыт только роли shop" : "Войдите как shop, чтобы управлять каталогом"} +
+ `; + } else { + elements.shopGate.innerHTML = ""; + } +} + +function addToCart(productId, quantity) { + const product = state.products.find((item) => item.id === Number(productId)); + if (!product) return; + const id = String(product.id); + const current = state.cart[id]?.quantity || 0; + const nextQuantity = Math.min(product.stock, current + quantity); + state.cart[id] = { product, quantity: nextQuantity }; + saveCart(); + render(); + showToast(`${product.name} добавлен в корзину`); +} + +function changeCartQuantity(productId, delta) { + const item = state.cart[String(productId)]; + if (!item) return; + const nextQuantity = item.quantity + delta; + if (nextQuantity < 1) { + delete state.cart[String(productId)]; + } else { + item.quantity = Math.min(nextQuantity, item.product.stock); + } + saveCart(); + render(); +} + +async function refreshOrders() { + try { + await loadOrders(); + renderOrders(); + } catch (error) { + showToast(error.message); + } +} + +async function submitAuth(form) { + const formData = new FormData(form); + const username = String(formData.get("username") || "").trim().toLowerCase(); + const password = String(formData.get("password") || ""); + + if (state.authMode === "register" && !isStrongPassword(password)) { + showToast("Пароль должен содержать верхний и нижний регистр, цифру и спецсимвол"); + return; + } + + try { + if (state.authMode === "register") { + await apiRequest("/auth/register", { + method: "POST", + body: JSON.stringify({ username, password }), + }); + } + const tokenResponse = await apiRequest("/auth/login", { + method: "POST", + body: JSON.stringify({ username, password }), + }); + state.token = tokenResponse.access_token; + localStorage.setItem(TOKEN_KEY, state.token); + await loadProfile(); + await loadOrders(); + render(); + showToast(`Добро пожаловать, ${state.user.username}`); + } catch (error) { + showToast(error.message); + } +} + +function isStrongPassword(value) { + return ( + value.length >= 12 && + /[A-Z]/.test(value) && + /[a-z]/.test(value) && + /\d/.test(value) && + /[^A-Za-z0-9]/.test(value) + ); +} + +async function logout() { + state.token = null; + state.user = null; + state.orders = []; + localStorage.removeItem(TOKEN_KEY); + render(); + showToast("Сессия завершена"); +} + +async function checkout() { + if (!state.user) { + showToast("Сначала войдите в аккаунт"); + return; + } + const items = Object.values(state.cart).map(({ product, quantity }) => ({ + product_id: product.id, + quantity, + })); + if (!items.length) return; + + try { + await apiRequest("/orders", { + method: "POST", + body: JSON.stringify({ items }), + }); + state.cart = {}; + saveCart(); + await loadProducts(); + await loadOrders(); + setView("orders"); + showToast("Заказ создан"); + } catch (error) { + showToast(error.message); + } +} + +async function payOrder(orderId) { + try { + await apiRequest(`/payments/orders/${orderId}/confirm`, { method: "POST" }); + await loadOrders(); + renderOrders(); + showToast("Оплата подтверждена"); + } catch (error) { + showToast(error.message); + } +} + +async function createProduct(form) { + if (state.user?.role !== "shop") { + showToast("Недостаточно прав"); + return; + } + const formData = new FormData(form); + const description = String(formData.get("description") || "").trim(); + const payload = { + name: String(formData.get("name") || "").trim(), + description: description || null, + price: String(formData.get("price") || "0"), + stock: Number(formData.get("stock") || 0), + is_active: true, + }; + + try { + await apiRequest("/products", { + method: "POST", + body: JSON.stringify(payload), + }); + form.reset(); + await loadProducts(); + render(); + showToast("Товар добавлен"); + } catch (error) { + showToast(error.message); + } +} + +function bindEvents() { + elements.tabs.forEach((tab) => { + tab.addEventListener("click", () => setView(tab.dataset.view)); + }); + + elements.searchInput.addEventListener("input", (event) => { + state.search = event.target.value; + renderCatalog(); + }); + + elements.sortSelect.addEventListener("change", (event) => { + state.sort = event.target.value; + renderCatalog(); + }); + + elements.productGrid.addEventListener("click", (event) => { + const button = event.target.closest("[data-add]"); + if (!button) return; + const productId = button.dataset.add; + const qtyInput = elements.productGrid.querySelector(`[data-qty="${productId}"]`); + const quantity = Math.max(1, Number(qtyInput?.value || 1)); + addToCart(productId, quantity); + }); + + elements.authPanel.addEventListener("click", (event) => { + const authModeButton = event.target.closest("[data-auth-mode]"); + if (authModeButton) { + state.authMode = authModeButton.dataset.authMode; + renderAuthPanel(); + return; + } + if (event.target.closest("[data-logout]")) { + logout(); + } + }); + + elements.authPanel.addEventListener("submit", (event) => { + if (event.target.id !== "authForm") return; + event.preventDefault(); + submitAuth(event.target); + }); + + elements.cartList.addEventListener("click", (event) => { + const increase = event.target.closest("[data-increase]"); + const decrease = event.target.closest("[data-decrease]"); + if (increase) changeCartQuantity(increase.dataset.increase, 1); + if (decrease) changeCartQuantity(decrease.dataset.decrease, -1); + }); + + elements.checkoutButton.addEventListener("click", checkout); + elements.clearCartButton.addEventListener("click", () => { + state.cart = {}; + saveCart(); + render(); + }); + + elements.ordersList.addEventListener("click", (event) => { + const payButton = event.target.closest("[data-pay]"); + if (payButton) payOrder(payButton.dataset.pay); + }); + + elements.productForm.addEventListener("submit", (event) => { + event.preventDefault(); + createProduct(event.target); + }); +} + +async function init() { + bindEvents(); + render(); + await checkHealth(); + try { + if (state.token) { + await loadProfile(); + await loadOrders(); + } + } catch { + localStorage.removeItem(TOKEN_KEY); + state.token = null; + state.user = null; + } + try { + await loadProducts(); + } catch (error) { + showToast(error.message); + } + render(); +} + +init(); diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..6cfdfd8 --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,117 @@ + + + + + + SecureShop MVP + + + +
+
+ + S + + SecureShop + защищенный online-shop MVP + + + + + +
Гость
+
+ +
+
+
+
+

Retail security flow

+

Каталог товаров

+
+
+ 0 товаров + 0 в корзине + API +
+
+ +
+
+ + +
+
+
+ +
+
+
+ +
+
+
+

Shop role

+

Новый товар

+
+
+ + + + +
+ +
+
+
+
+ + +
+
+ +
+ + + diff --git a/app/static/styles.css b/app/static/styles.css new file mode 100644 index 0000000..9e19eb0 --- /dev/null +++ b/app/static/styles.css @@ -0,0 +1,712 @@ +:root { + color-scheme: light; + --bg: #f6f4ef; + --surface: #ffffff; + --surface-strong: #111827; + --text: #17202a; + --muted: #667085; + --line: #dedbd2; + --green: #13795b; + --green-dark: #0c5f47; + --coral: #c84a31; + --amber: #b7791f; + --blue: #2563eb; + --shadow: 0 18px 55px rgba(23, 32, 42, 0.11); + --radius: 8px; +} + +* { + box-sizing: border-box; +} + +html { + min-width: 320px; +} + +body { + margin: 0; + min-height: 100vh; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(246, 244, 239, 0.92)), + var(--bg); + color: var(--text); + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button { + cursor: pointer; +} + +.app-shell { + width: min(1460px, 100%); + margin: 0 auto; + padding: 18px; +} + +.topbar { + position: sticky; + top: 0; + z-index: 20; + display: grid; + grid-template-columns: minmax(220px, 1fr) auto minmax(130px, 1fr); + align-items: center; + gap: 16px; + padding: 12px 14px; + border: 1px solid rgba(222, 219, 210, 0.86); + border-radius: var(--radius); + background: rgba(255, 255, 255, 0.93); + box-shadow: 0 10px 32px rgba(23, 32, 42, 0.08); + backdrop-filter: blur(14px); +} + +.brand { + display: inline-flex; + align-items: center; + gap: 12px; + min-width: 0; + color: inherit; + text-decoration: none; +} + +.brand-mark { + display: grid; + width: 42px; + height: 42px; + place-items: center; + border-radius: var(--radius); + background: #16251e; + color: #f7d56c; + font-weight: 800; +} + +.brand strong, +.brand small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.brand strong { + font-size: 1rem; +} + +.brand small { + color: var(--muted); + font-size: 0.78rem; +} + +.tabs { + display: inline-flex; + gap: 6px; + padding: 4px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: #f0eee8; +} + +.tab { + min-width: 90px; + border: 0; + border-radius: 6px; + background: transparent; + color: #475467; + padding: 9px 12px; + font-weight: 700; +} + +.tab.is-active { + background: var(--surface); + color: var(--text); + box-shadow: 0 4px 16px rgba(23, 32, 42, 0.09); +} + +.session-pill { + justify-self: end; + max-width: 100%; + padding: 9px 12px; + border-radius: 999px; + background: #eef8f4; + color: var(--green-dark); + font-weight: 800; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 370px; + gap: 18px; + align-items: start; + padding-top: 18px; +} + +.workspace, +.side-panel { + min-width: 0; +} + +.workspace-head { + display: flex; + justify-content: space-between; + gap: 18px; + align-items: end; + margin-bottom: 16px; +} + +.eyebrow { + margin: 0 0 6px; + color: var(--green); + font-size: 0.74rem; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +h1, +h2, +h3, +p { + margin-top: 0; +} + +h1 { + margin-bottom: 0; + font-size: clamp(2rem, 4vw, 4.25rem); + line-height: 0.98; +} + +h2 { + margin-bottom: 0; + font-size: 1.15rem; +} + +.metrics { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.metrics span { + padding: 9px 11px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: rgba(255, 255, 255, 0.84); + color: #344054; + font-size: 0.9rem; + font-weight: 800; +} + +.toolbar { + display: grid; + grid-template-columns: minmax(220px, 1fr) minmax(170px, 230px); + gap: 12px; + margin-bottom: 14px; +} + +label { + display: grid; + gap: 7px; + color: #344054; + font-size: 0.82rem; + font-weight: 800; +} + +input, +select, +textarea { + width: 100%; + min-width: 0; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface); + color: var(--text); + padding: 12px 13px; + outline: none; +} + +textarea { + min-height: 104px; + resize: vertical; +} + +input:focus, +select:focus, +textarea:focus { + border-color: var(--green); + box-shadow: 0 0 0 4px rgba(19, 121, 91, 0.13); +} + +.view { + display: none; +} + +.view.is-active { + display: block; +} + +.product-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(235px, 1fr)); + gap: 14px; +} + +.product-card { + display: grid; + min-height: 402px; + overflow: hidden; + border: 1px solid rgba(222, 219, 210, 0.92); + border-radius: var(--radius); + background: var(--surface); + box-shadow: 0 12px 28px rgba(23, 32, 42, 0.07); +} + +.product-card img { + width: 100%; + height: 168px; + object-fit: cover; + background: #ece8de; +} + +.product-body { + display: grid; + grid-template-rows: auto 1fr auto; + gap: 12px; + padding: 14px; +} + +.product-title-row { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: start; +} + +.product-title-row h3 { + margin: 0; + font-size: 1.02rem; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.price { + flex: 0 0 auto; + color: var(--green-dark); + font-weight: 900; +} + +.product-card p { + color: var(--muted); + font-size: 0.9rem; + line-height: 1.45; +} + +.stock { + display: inline-flex; + width: fit-content; + align-items: center; + gap: 6px; + padding: 6px 8px; + border-radius: 999px; + background: #f6f0df; + color: #6b4e16; + font-size: 0.78rem; + font-weight: 900; +} + +.card-actions { + display: grid; + grid-template-columns: 84px 1fr; + gap: 8px; +} + +.card-actions input { + padding: 10px 8px; +} + +.primary-action, +.secondary-action, +.ghost-action, +.danger-action { + display: inline-flex; + min-height: 42px; + align-items: center; + justify-content: center; + gap: 8px; + border-radius: var(--radius); + padding: 10px 13px; + font-weight: 900; + text-align: center; +} + +.primary-action { + border: 1px solid var(--green); + background: var(--green); + color: #fff; +} + +.primary-action:hover { + background: var(--green-dark); +} + +.secondary-action { + border: 1px solid #c9d8ff; + background: #eef4ff; + color: #1e4ab5; +} + +.ghost-action { + min-height: 36px; + border: 1px solid var(--line); + background: #fff; + color: #475467; +} + +.danger-action { + border: 1px solid #f0b8ab; + background: #fff3f0; + color: var(--coral); +} + +button:disabled { + cursor: not-allowed; + opacity: 0.52; +} + +.side-panel { + position: sticky; + top: 90px; + display: grid; + gap: 14px; +} + +.panel-block, +.shop-form, +.empty-state, +.order-card { + border: 1px solid rgba(222, 219, 210, 0.92); + border-radius: var(--radius); + background: rgba(255, 255, 255, 0.94); + box-shadow: var(--shadow); +} + +.panel-block, +.shop-form { + padding: 16px; +} + +.panel-title, +.form-head { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: center; + margin-bottom: 14px; +} + +.auth-tabs { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; + margin-bottom: 14px; + padding: 4px; + border-radius: var(--radius); + background: #f0eee8; +} + +.auth-tabs button { + border: 0; + border-radius: 6px; + background: transparent; + padding: 9px; + color: #475467; + font-weight: 900; +} + +.auth-tabs button.is-active { + background: #fff; + color: var(--text); + box-shadow: 0 4px 16px rgba(23, 32, 42, 0.08); +} + +.auth-form { + display: grid; + gap: 12px; +} + +.account-card { + display: grid; + gap: 12px; +} + +.account-name { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: center; +} + +.role-badge, +.status-badge { + display: inline-flex; + width: fit-content; + align-items: center; + border-radius: 999px; + padding: 6px 9px; + background: #eef8f4; + color: var(--green-dark); + font-size: 0.78rem; + font-weight: 900; +} + +.status-badge.pending { + background: #fff7e8; + color: var(--amber); +} + +.status-badge.paid { + background: #e9f8f1; + color: var(--green); +} + +.cart-list { + display: grid; + gap: 10px; + min-height: 60px; +} + +.cart-row { + display: grid; + grid-template-columns: 1fr auto; + gap: 10px; + align-items: center; + padding: 10px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: #fbfaf7; +} + +.cart-row strong { + display: block; + overflow-wrap: anywhere; +} + +.cart-row small { + color: var(--muted); +} + +.cart-controls { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.icon-button { + display: grid; + width: 32px; + height: 32px; + place-items: center; + border: 1px solid var(--line); + border-radius: var(--radius); + background: #fff; + color: var(--text); + font-weight: 900; +} + +.cart-total { + display: flex; + justify-content: space-between; + align-items: center; + margin: 14px 0; + padding-top: 14px; + border-top: 1px solid var(--line); +} + +.cart-total strong { + font-size: 1.35rem; + color: var(--green-dark); +} + +.empty-state { + display: grid; + min-height: 220px; + place-items: center; + padding: 24px; + color: var(--muted); + text-align: center; +} + +.orders-list { + display: grid; + gap: 12px; +} + +.order-card { + padding: 16px; +} + +.order-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: start; + margin-bottom: 12px; +} + +.order-head h3 { + margin: 0 0 4px; +} + +.order-head small { + color: var(--muted); +} + +.order-items { + display: grid; + gap: 8px; + margin: 12px 0; +} + +.order-item { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 9px 0; + border-bottom: 1px solid #eeeae1; + color: #344054; +} + +.order-item span:first-child { + overflow-wrap: anywhere; +} + +.order-footer { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 14px; +} + +.form-grid .wide { + grid-column: 1 / -1; +} + +.shop-empty { + display: none; +} + +.shop-empty.is-visible { + display: block; + margin-top: 12px; +} + +.toast { + position: fixed; + right: 20px; + bottom: 20px; + z-index: 50; + max-width: min(380px, calc(100vw - 40px)); + transform: translateY(18px); + opacity: 0; + pointer-events: none; + border-radius: var(--radius); + background: #17202a; + color: #fff; + padding: 13px 15px; + box-shadow: 0 18px 55px rgba(23, 32, 42, 0.26); + transition: transform 180ms ease, opacity 180ms ease; +} + +.toast.is-visible { + transform: translateY(0); + opacity: 1; +} + +@media (max-width: 1080px) { + .layout { + grid-template-columns: 1fr; + } + + .side-panel { + position: static; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 780px) { + .app-shell { + padding: 10px; + } + + .topbar { + position: static; + grid-template-columns: 1fr; + } + + .tabs, + .session-pill { + justify-self: stretch; + } + + .tabs { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .tab { + min-width: 0; + padding-inline: 6px; + } + + .workspace-head { + display: grid; + align-items: start; + } + + h1 { + font-size: 2.25rem; + } + + .toolbar, + .side-panel, + .form-grid { + grid-template-columns: 1fr; + } + + .metrics { + justify-content: start; + } +} + +@media (max-width: 480px) { + .product-grid { + grid-template-columns: 1fr; + } + + .card-actions { + grid-template-columns: 74px 1fr; + } + + .order-footer, + .order-head { + display: grid; + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9f55a8b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi>=0.111.0,<1.0.0 +uvicorn[standard]>=0.30.0,<1.0.0 +sqlalchemy>=2.0.30,<3.0.0 +pydantic>=2.7.0,<3.0.0 +pydantic-settings>=2.2.1,<3.0.0 +passlib[bcrypt]>=1.7.4,<2.0.0 +bcrypt>=4.0.1,<4.1.0 +python-jose[cryptography]>=3.3.0,<4.0.0