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

This commit is contained in:
konturai-ops
2026-08-10 15:26:59 +00:00
commit 2022c8890d
44 changed files with 3196 additions and 0 deletions
+20
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.pyc
source.tar.gz
._*
app/__pycache__/
app/*/__pycache__/
+22
View File
@@ -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"]
+123
View File
@@ -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`
+1
View File
@@ -0,0 +1 @@
"""Application package."""
+1
View File
@@ -0,0 +1 @@
"""API package."""
+69
View File
@@ -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)
+37
View File
@@ -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
+58
View File
@@ -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)
+29
View File
@@ -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)
+42
View File
@@ -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)
+9
View File
@@ -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"])
+1
View File
@@ -0,0 +1 @@
"""Core application utilities."""
+79
View File
@@ -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]
+82
View File
@@ -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"},
)
+86
View File
@@ -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"],
},
}
)
+92
View File
@@ -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,
)
+62
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Database package."""
+101
View File
@@ -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)
+27
View File
@@ -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")
+61
View File
@@ -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,
)
+27
View File
@@ -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
View File
@@ -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]
+15
View File
@@ -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",
]
+5
View File
@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
+45
View File
@@ -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",
)
+31
View File
@@ -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
+29
View File
@@ -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,
)
+35
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
"""Pydantic schemas package."""
+85
View File
@@ -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"
+51
View File
@@ -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]
+17
View File
@@ -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
+38
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Service layer package."""
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
import hashlib
import logging
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.exceptions import ConflictError, UnauthorizedError
from app.core.security import create_access_token, get_password_hash, verify_password
from app.models.user import RoleEnum, User
from app.schemas.auth import LoginRequest, UserCreate
audit_logger = logging.getLogger("app.audit")
class AuthService:
def __init__(self, db: Session) -> None:
self.db = db
def register_user(self, payload: UserCreate) -> User:
existing_user = self.db.scalar(select(User).where(User.username == payload.username))
if existing_user is not None:
raise ConflictError("Username already exists")
user = User(
username=payload.username,
password_hash=get_password_hash(payload.password),
role=RoleEnum.client,
is_active=True,
)
self.db.add(user)
try:
self.db.commit()
self.db.refresh(user)
except IntegrityError as exc:
self.db.rollback()
raise ConflictError("Username already exists") from exc
audit_logger.info("user_registered user_id=%s role=%s", user.id, user.role.value)
return user
def authenticate_user(self, payload: LoginRequest) -> str:
user = self.db.scalar(select(User).where(User.username == payload.username))
subject_hash = self._hash_subject(payload.username)
if user is None or not verify_password(payload.password, user.password_hash):
audit_logger.warning("login_failed subject_hash=%s", subject_hash)
raise UnauthorizedError("Invalid username or password")
if not user.is_active:
audit_logger.warning("login_rejected_inactive user_id=%s", user.id)
raise UnauthorizedError("Invalid username or password")
access_token = create_access_token(subject=str(user.id))
audit_logger.info("login_success user_id=%s role=%s", user.id, user.role.value)
return access_token
def get_user_by_id(self, user_id: int) -> User | None:
return self.db.get(User, user_id)
@staticmethod
def _hash_subject(subject: str) -> str:
return hashlib.sha256(subject.encode("utf-8")).hexdigest()[:12]
+104
View File
@@ -0,0 +1,104 @@
from __future__ import annotations
import logging
from decimal import Decimal, ROUND_HALF_UP
from sqlalchemy import select, update
from sqlalchemy.orm import Session, selectinload
from app.core.exceptions import BadRequestError, NotFoundError
from app.models.order import Order, OrderStatusEnum
from app.models.order_item import OrderItem
from app.models.product import Product
from app.models.user import User
from app.schemas.order import OrderCreate
audit_logger = logging.getLogger("app.audit")
MONEY_QUANTIZER = Decimal("0.01")
def _to_money(value: Decimal) -> Decimal:
return value.quantize(MONEY_QUANTIZER, rounding=ROUND_HALF_UP)
class OrderService:
def __init__(self, db: Session) -> None:
self.db = db
def create_order(self, user: User, payload: OrderCreate) -> Order:
product_ids = [item.product_id for item in payload.items]
products = self.db.scalars(
select(Product).where(Product.id.in_(product_ids), Product.is_active.is_(True))
).all()
if len(products) != len(product_ids):
raise NotFoundError("One or more requested products are unavailable")
product_map = {product.id: product for product in products}
order = Order(user_id=user.id, total_amount=Decimal("0.00"), status=OrderStatusEnum.pending)
try:
self.db.add(order)
self.db.flush()
total_amount = Decimal("0.00")
for item in payload.items:
product = product_map[item.product_id]
update_result = self.db.execute(
update(Product)
.where(
Product.id == product.id,
Product.is_active.is_(True),
Product.stock >= item.quantity,
)
.values(stock=Product.stock - item.quantity)
)
if update_result.rowcount != 1:
raise BadRequestError(
f"Insufficient stock for product '{product.name}'"
)
unit_price = _to_money(Decimal(product.price))
subtotal = _to_money(unit_price * item.quantity)
order_item = OrderItem(
order_id=order.id,
product_id=product.id,
quantity=item.quantity,
unit_price=unit_price,
subtotal=subtotal,
)
self.db.add(order_item)
total_amount += subtotal
order.total_amount = _to_money(total_amount)
self.db.commit()
except Exception:
self.db.rollback()
raise
audit_logger.info(
"order_created order_id=%s user_id=%s total_amount=%s",
order.id,
user.id,
str(order.total_amount),
)
return self.get_order_for_user(order.id, user)
def get_orders_for_user(self, user: User) -> list[Order]:
statement = (
select(Order)
.where(Order.user_id == user.id)
.options(selectinload(Order.items).selectinload(OrderItem.product))
.order_by(Order.created_at.desc())
)
return list(self.db.scalars(statement).all())
def get_order_for_user(self, order_id: int, user: User) -> Order:
statement = (
select(Order)
.where(Order.id == order_id, Order.user_id == user.id)
.options(selectinload(Order.items).selectinload(OrderItem.product))
)
order = self.db.scalar(statement)
if order is None:
raise NotFoundError("Order not found")
return order
+41
View File
@@ -0,0 +1,41 @@
from __future__ import annotations
import logging
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from app.core.exceptions import BadRequestError
from app.models.order import Order, OrderStatusEnum
from app.models.user import User
from app.services.order_service import OrderService
audit_logger = logging.getLogger("app.audit")
class PaymentService:
def __init__(self, db: Session) -> None:
self.db = db
self.order_service = OrderService(db)
def confirm_payment(self, order_id: int, user: User) -> Order:
order = self.order_service.get_order_for_user(order_id, user)
if order.status != OrderStatusEnum.pending:
raise BadRequestError("This order is not eligible for payment")
try:
order.status = OrderStatusEnum.paid
order.paid_at = datetime.now(timezone.utc)
self.db.commit()
except Exception:
self.db.rollback()
raise
audit_logger.info(
"payment_confirmed order_id=%s user_id=%s total_amount=%s",
order.id,
user.id,
str(order.total_amount),
)
return self.order_service.get_order_for_user(order.id, user)
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
import logging
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.exceptions import ForbiddenError
from app.models.product import Product
from app.models.user import RoleEnum, User
from app.schemas.product import ProductCreate
audit_logger = logging.getLogger("app.audit")
class ProductService:
def __init__(self, db: Session) -> None:
self.db = db
def list_available_products(self) -> list[Product]:
statement = (
select(Product)
.where(Product.is_active.is_(True), Product.stock > 0)
.order_by(Product.created_at.desc())
)
return list(self.db.scalars(statement).all())
def create_product(self, payload: ProductCreate, actor: User) -> Product:
if actor.role != RoleEnum.shop:
raise ForbiddenError("Shop privileges are required")
product = Product(
name=payload.name,
description=payload.description,
price=payload.price,
stock=payload.stock,
is_active=payload.is_active,
)
self.db.add(product)
try:
self.db.commit()
self.db.refresh(product)
except Exception:
self.db.rollback()
raise
audit_logger.info("product_created product_id=%s shop_user_id=%s", product.id, actor.id)
return product
+630
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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();
+117
View File
@@ -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>
+712
View File
@@ -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;
}
}
+8
View File
@@ -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