sync: migrate secure-online-shop to Gitea (2026-08-10)
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
source.tar.gz
|
||||
._*
|
||||
app/__pycache__/
|
||||
app/*/__pycache__/
|
||||
+22
@@ -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"]
|
||||
@@ -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`
|
||||
@@ -0,0 +1 @@
|
||||
"""Application package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""API package."""
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
@@ -0,0 +1 @@
|
||||
"""Core application utilities."""
|
||||
@@ -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]
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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"],
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Database package."""
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
+80
@@ -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]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
"""Pydantic schemas package."""
|
||||
@@ -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"
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Service layer package."""
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 = `<div class="empty-state">Товары не найдены</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
elements.productGrid.innerHTML = products
|
||||
.map((product) => {
|
||||
const description = product.description || "Товар доступен для заказа";
|
||||
const image = artwork[product.id % artwork.length];
|
||||
return `
|
||||
<article class="product-card">
|
||||
<img src="${image}" alt="${escapeHtml(product.name)}" loading="lazy" />
|
||||
<div class="product-body">
|
||||
<div>
|
||||
<div class="product-title-row">
|
||||
<h3>${escapeHtml(product.name)}</h3>
|
||||
<span class="price">${money(product.price)}</span>
|
||||
</div>
|
||||
<span class="stock">${product.stock} шт.</span>
|
||||
</div>
|
||||
<p>${escapeHtml(description)}</p>
|
||||
<div class="card-actions">
|
||||
<input aria-label="Количество ${escapeHtml(product.name)}" type="number" min="1" max="${product.stock}" value="1" data-qty="${product.id}" />
|
||||
<button class="primary-action" type="button" data-add="${product.id}">В корзину</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderAuthPanel() {
|
||||
if (state.user) {
|
||||
elements.authPanel.innerHTML = `
|
||||
<div class="panel-title">
|
||||
<h2>Аккаунт</h2>
|
||||
<span class="role-badge">${escapeHtml(state.user.role)}</span>
|
||||
</div>
|
||||
<div class="account-card">
|
||||
<div class="account-name">
|
||||
<strong>${escapeHtml(state.user.username)}</strong>
|
||||
<small>ID ${state.user.id}</small>
|
||||
</div>
|
||||
<button class="ghost-action" type="button" data-logout>Выйти</button>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const isRegister = state.authMode === "register";
|
||||
elements.authPanel.innerHTML = `
|
||||
<div class="auth-tabs">
|
||||
<button type="button" class="${state.authMode === "login" ? "is-active" : ""}" data-auth-mode="login">Вход</button>
|
||||
<button type="button" class="${isRegister ? "is-active" : ""}" data-auth-mode="register">Регистрация</button>
|
||||
</div>
|
||||
<form class="auth-form" id="authForm">
|
||||
<label>
|
||||
Логин
|
||||
<input name="username" required minlength="3" maxlength="50" autocomplete="username" placeholder="client01" />
|
||||
</label>
|
||||
<label>
|
||||
Пароль
|
||||
<input name="password" required minlength="${isRegister ? 12 : 8}" maxlength="72" type="password" autocomplete="${isRegister ? "new-password" : "current-password"}" placeholder="${isRegister ? "StrongPass1!" : "password"}" />
|
||||
</label>
|
||||
<button class="primary-action" type="submit">${isRegister ? "Создать аккаунт" : "Войти"}</button>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderCart() {
|
||||
const items = Object.values(state.cart);
|
||||
if (!items.length) {
|
||||
elements.cartList.innerHTML = `<div class="empty-state">Корзина пуста</div>`;
|
||||
elements.cartTotal.textContent = money(0);
|
||||
elements.checkoutButton.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
elements.cartList.innerHTML = items
|
||||
.map(({ product, quantity }) => `
|
||||
<div class="cart-row">
|
||||
<div>
|
||||
<strong>${escapeHtml(product.name)}</strong>
|
||||
<small>${quantity} × ${money(product.price)}</small>
|
||||
</div>
|
||||
<div class="cart-controls">
|
||||
<button class="icon-button" type="button" aria-label="Уменьшить" data-decrease="${product.id}">−</button>
|
||||
<span>${quantity}</span>
|
||||
<button class="icon-button" type="button" aria-label="Увеличить" data-increase="${product.id}">+</button>
|
||||
</div>
|
||||
</div>
|
||||
`)
|
||||
.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 = `<div class="empty-state">Войдите, чтобы увидеть свои заказы</div>`;
|
||||
return;
|
||||
}
|
||||
if (!state.orders.length) {
|
||||
elements.ordersList.innerHTML = `<div class="empty-state">Заказов пока нет</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
elements.ordersList.innerHTML = state.orders
|
||||
.map((order) => `
|
||||
<article class="order-card">
|
||||
<div class="order-head">
|
||||
<div>
|
||||
<h3>Заказ #${order.id}</h3>
|
||||
<small>${dateTime(order.created_at)}</small>
|
||||
</div>
|
||||
<span class="status-badge ${order.status}">${order.status}</span>
|
||||
</div>
|
||||
<div class="order-items">
|
||||
${order.items
|
||||
.map((item) => `
|
||||
<div class="order-item">
|
||||
<span>${escapeHtml(item.product_name)} × ${item.quantity}</span>
|
||||
<strong>${money(item.subtotal)}</strong>
|
||||
</div>
|
||||
`)
|
||||
.join("")}
|
||||
</div>
|
||||
<div class="order-footer">
|
||||
<strong>${money(order.total_amount)}</strong>
|
||||
${
|
||||
order.status === "pending"
|
||||
? `<button class="secondary-action" type="button" data-pay="${order.id}">Оплатить</button>`
|
||||
: `<span class="role-badge">Оплачен</span>`
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
`)
|
||||
.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 = `
|
||||
<div class="empty-state">
|
||||
${state.user ? "Доступ к созданию товаров открыт только роли shop" : "Войдите как shop, чтобы управлять каталогом"}
|
||||
</div>
|
||||
`;
|
||||
} 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();
|
||||
@@ -0,0 +1,117 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>SecureShop MVP</title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/" aria-label="SecureShop">
|
||||
<span class="brand-mark">S</span>
|
||||
<span>
|
||||
<strong>SecureShop</strong>
|
||||
<small>защищенный online-shop MVP</small>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<nav class="tabs" aria-label="Разделы">
|
||||
<button class="tab is-active" type="button" data-view="catalog">Каталог</button>
|
||||
<button class="tab" type="button" data-view="orders">Заказы</button>
|
||||
<button class="tab" type="button" data-view="shop">Магазин</button>
|
||||
</nav>
|
||||
|
||||
<div class="session-pill" id="sessionPill">Гость</div>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<section class="workspace" aria-live="polite">
|
||||
<div class="workspace-head">
|
||||
<div>
|
||||
<p class="eyebrow">Retail security flow</p>
|
||||
<h1 id="viewTitle">Каталог товаров</h1>
|
||||
</div>
|
||||
<div class="metrics">
|
||||
<span id="catalogMetric">0 товаров</span>
|
||||
<span id="cartMetric">0 в корзине</span>
|
||||
<span id="healthMetric">API</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="view is-active" id="catalogView" aria-label="Каталог">
|
||||
<div class="toolbar">
|
||||
<label class="search-field">
|
||||
<span>Поиск</span>
|
||||
<input id="searchInput" type="search" autocomplete="off" placeholder="ноутбук, камера, рюкзак" />
|
||||
</label>
|
||||
<label class="select-field">
|
||||
<span>Сортировка</span>
|
||||
<select id="sortSelect">
|
||||
<option value="new">Сначала новые</option>
|
||||
<option value="price-asc">Цена по возрастанию</option>
|
||||
<option value="price-desc">Цена по убыванию</option>
|
||||
<option value="stock">Больше остатков</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="product-grid" id="productGrid"></div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="ordersView" aria-label="Заказы">
|
||||
<div class="orders-list" id="ordersList"></div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="shopView" aria-label="Панель магазина">
|
||||
<form class="shop-form" id="productForm">
|
||||
<div class="form-head">
|
||||
<p class="eyebrow">Shop role</p>
|
||||
<h2>Новый товар</h2>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
Название
|
||||
<input name="name" required minlength="3" maxlength="120" placeholder="Secure Router" />
|
||||
</label>
|
||||
<label>
|
||||
Цена
|
||||
<input name="price" required type="number" min="0.01" step="0.01" placeholder="149.99" />
|
||||
</label>
|
||||
<label>
|
||||
Остаток
|
||||
<input name="stock" required type="number" min="0" max="1000000" step="1" placeholder="24" />
|
||||
</label>
|
||||
<label class="wide">
|
||||
Описание
|
||||
<textarea name="description" maxlength="1000" placeholder="Краткое описание товара"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<button class="primary-action" type="submit">Добавить товар</button>
|
||||
</form>
|
||||
<div class="shop-empty" id="shopGate"></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<aside class="side-panel" aria-label="Покупка и аккаунт">
|
||||
<section class="panel-block" id="authPanel"></section>
|
||||
<section class="panel-block">
|
||||
<div class="panel-title">
|
||||
<h2>Корзина</h2>
|
||||
<button class="ghost-action" type="button" id="clearCartButton">Очистить</button>
|
||||
</div>
|
||||
<div class="cart-list" id="cartList"></div>
|
||||
<div class="cart-total">
|
||||
<span>Итого</span>
|
||||
<strong id="cartTotal">$0.00</strong>
|
||||
</div>
|
||||
<button class="primary-action" type="button" id="checkoutButton">Оформить заказ</button>
|
||||
</section>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||||
<script src="/static/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user