{{ item.title }} × {{ item.quantity }}
+{{ item.unit_price | rub }}
+ {% if item.delivered_codes %} +{{ item.delivered_codes }}
+ {% endif %}
+diff --git a/.env.example b/.env.example index 7708c90..cb233e6 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,10 @@ APP_NAME=Pitopn SECRET_KEY=change-me-to-a-long-random-string DEBUG=false +# Admin panel (/admin) +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me-strong-admin-password + # Database (PostgreSQL 17) POSTGRES_USER=pitopn POSTGRES_PASSWORD=change-me-strong-password diff --git a/README.md b/README.md index 275ddd5..b470b91 100644 --- a/README.md +++ b/README.md @@ -38,13 +38,34 @@ docker compose up --build -d База `db` не публикуется наружу — только внутри сети Compose. Данные PostgreSQL хранятся в volume `postgres_data`. +## Админка + +URL: `/admin` + +Логин и пароль задаются в `.env`: + +```env +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me-strong-admin-password +``` + +В админке: дашборд, товары, загрузка ключей, заказы. + +## Магазин + +- Каталог и карточка товара +- Корзина (сессия) +- Оформление с демо-оплатой и мгновенной выдачей кодов +- Поиск заказа: `/lookup` + ## Структура ``` app/ - main.py # FastAPI + главная страница - models.py # Product, Category - seed.py # демо-товары при первом запуске + main.py # FastAPI + сессии + models.py # Product, ProductKey, Order + routers/shop.py # витрина, корзина, заказы + routers/admin.py # админка templates/ # Jinja2 static/ # CSS/JS Dockerfile diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..b632a42 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import hashlib +import secrets + +from fastapi import Request + +from app.config import get_settings + + +def _secure_equal(left: str, right: str) -> bool: + left_digest = hashlib.sha256(left.encode("utf-8")).digest() + right_digest = hashlib.sha256(right.encode("utf-8")).digest() + return secrets.compare_digest(left_digest, right_digest) + + +def is_admin(request: Request) -> bool: + return bool(request.session.get("is_admin")) + + +def login_admin(request: Request, username: str, password: str) -> bool: + settings = get_settings() + if _secure_equal(username, settings.admin_username) and _secure_equal( + password, settings.admin_password + ): + request.session["is_admin"] = True + request.session["admin_user"] = username + return True + return False + + +def logout_admin(request: Request) -> None: + request.session.pop("is_admin", None) + request.session.pop("admin_user", None) diff --git a/app/cart.py b/app/cart.py new file mode 100644 index 0000000..9f675a1 --- /dev/null +++ b/app/cart.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from decimal import Decimal + +from fastapi import Request +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.models import Product, ProductKey + + +def get_cart_map(request: Request) -> dict[str, int]: + raw = request.session.get("cart") or {} + cart: dict[str, int] = {} + for key, value in raw.items(): + try: + qty = int(value) + except (TypeError, ValueError): + continue + if qty > 0: + cart[str(key)] = qty + return cart + + +def save_cart(request: Request, cart: dict[str, int]) -> None: + request.session["cart"] = {str(k): int(v) for k, v in cart.items() if int(v) > 0} + + +def cart_count(request: Request) -> int: + return sum(get_cart_map(request).values()) + + +def available_stock(db: Session, product_id: int) -> int: + return int( + db.scalar( + select(func.count()) + .select_from(ProductKey) + .where(ProductKey.product_id == product_id, ProductKey.is_sold.is_(False)) + ) + or 0 + ) + + +def build_cart_items(db: Session, request: Request) -> tuple[list[dict], Decimal]: + cart = get_cart_map(request) + items: list[dict] = [] + total = Decimal("0.00") + if not cart: + return items, total + + product_ids = [int(pid) for pid in cart.keys()] + products = { + p.id: p + for p in db.scalars(select(Product).where(Product.id.in_(product_ids))).all() + } + + clean_cart = dict(cart) + for pid_str, qty in cart.items(): + product = products.get(int(pid_str)) + if not product or not product.is_active: + clean_cart.pop(pid_str, None) + continue + stock = available_stock(db, product.id) + qty = min(qty, max(stock, 0)) + if qty <= 0: + clean_cart.pop(pid_str, None) + continue + clean_cart[pid_str] = qty + line_total = product.price * qty + total += line_total + items.append( + { + "product": product, + "quantity": qty, + "stock": stock, + "line_total": line_total, + } + ) + + if clean_cart != cart: + save_cart(request, clean_cart) + return items, total diff --git a/app/config.py b/app/config.py index 78bb024..74fec6a 100644 --- a/app/config.py +++ b/app/config.py @@ -10,6 +10,9 @@ class Settings(BaseSettings): secret_key: str = "dev-secret" debug: bool = False + admin_username: str = "admin" + admin_password: str = "admin123" + postgres_user: str = "pitopn" postgres_password: str = "pitopn" postgres_db: str = "pitopn" diff --git a/app/main.py b/app/main.py index e0d35d5..2178089 100644 --- a/app/main.py +++ b/app/main.py @@ -2,17 +2,16 @@ import time from contextlib import asynccontextmanager from pathlib import Path -from fastapi import Depends, FastAPI, Request -from fastapi.responses import HTMLResponse +from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from sqlalchemy import select from sqlalchemy.exc import OperationalError -from sqlalchemy.orm import Session +from starlette.middleware.sessions import SessionMiddleware from app.config import get_settings -from app.database import Base, engine, get_db -from app.models import Category, Product +from app.database import Base, engine +from app.routers import admin as admin_router +from app.routers import shop as shop_router from app.seed import seed_if_empty BASE_DIR = Path(__file__).resolve().parent @@ -46,7 +45,15 @@ async def lifespan(_: FastAPI): app = FastAPI(title=settings.app_name, lifespan=lifespan) +app.add_middleware( + SessionMiddleware, + secret_key=settings.secret_key, + session_cookie="pitopn_session", + same_site="lax", + https_only=False, +) app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static") + templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) @@ -61,37 +68,12 @@ def format_price(value) -> str: templates.env.filters["rub"] = format_price +shop_router.setup(templates) +admin_router.setup(templates) +app.include_router(shop_router.router) +app.include_router(admin_router.router) + @app.get("/health") def health() -> dict[str, str]: return {"status": "ok"} - - -@app.get("/", response_class=HTMLResponse) -def home(request: Request, db: Session = Depends(get_db)): - categories = db.scalars( - select(Category).order_by(Category.sort_order, Category.name) - ).all() - featured = db.scalars( - select(Product) - .where(Product.is_active.is_(True), Product.is_featured.is_(True)) - .order_by(Product.id) - .limit(4) - ).all() - products = db.scalars( - select(Product) - .where(Product.is_active.is_(True)) - .order_by(Product.is_featured.desc(), Product.id) - .limit(12) - ).all() - - return templates.TemplateResponse( - request, - "index.html", - { - "app_name": settings.app_name, - "categories": categories, - "featured": featured, - "products": products, - }, - ) diff --git a/app/models.py b/app/models.py index 27897b1..c8f9afb 100644 --- a/app/models.py +++ b/app/models.py @@ -1,8 +1,17 @@ from datetime import datetime from decimal import Decimal -from sqlalchemy import Boolean, DateTime, Numeric, String, Text, func -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import ( + Boolean, + DateTime, + ForeignKey, + Integer, + Numeric, + String, + Text, + func, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database import Base @@ -35,3 +44,58 @@ class Product(Base): created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) + + keys: Mapped[list["ProductKey"]] = relationship(back_populates="product") + + +class ProductKey(Base): + __tablename__ = "product_keys" + + id: Mapped[int] = mapped_column(primary_key=True) + product_id: Mapped[int] = mapped_column(ForeignKey("products.id"), index=True) + code: Mapped[str] = mapped_column(Text) + is_sold: Mapped[bool] = mapped_column(Boolean, default=False, index=True) + order_id: Mapped[int | None] = mapped_column(ForeignKey("orders.id"), nullable=True) + sold_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + product: Mapped["Product"] = relationship(back_populates="keys") + order: Mapped["Order | None"] = relationship(back_populates="keys") + + +class Order(Base): + __tablename__ = "orders" + + id: Mapped[int] = mapped_column(primary_key=True) + public_id: Mapped[str] = mapped_column(String(32), unique=True, index=True) + email: Mapped[str] = mapped_column(String(255), index=True) + status: Mapped[str] = mapped_column(String(32), default="pending", index=True) + total: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=Decimal("0.00")) + customer_note: Mapped[str] = mapped_column(Text, default="") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + delivered_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + items: Mapped[list["OrderItem"]] = relationship(back_populates="order") + keys: Mapped[list["ProductKey"]] = relationship(back_populates="order") + + +class OrderItem(Base): + __tablename__ = "order_items" + + id: Mapped[int] = mapped_column(primary_key=True) + order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True) + product_id: Mapped[int] = mapped_column(ForeignKey("products.id"), index=True) + title: Mapped[str] = mapped_column(String(200)) + unit_price: Mapped[Decimal] = mapped_column(Numeric(10, 2)) + quantity: Mapped[int] = mapped_column(Integer, default=1) + delivered_codes: Mapped[str] = mapped_column(Text, default="") + + order: Mapped["Order"] = relationship(back_populates="items") + product: Mapped["Product"] = relationship() diff --git a/app/orders.py b/app/orders.py new file mode 100644 index 0000000..86e605d --- /dev/null +++ b/app/orders.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import secrets +from datetime import datetime, timezone +from decimal import Decimal + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.cart import available_stock +from app.models import Order, OrderItem, Product, ProductKey + + +class OrderError(Exception): + pass + + +def make_public_id() -> str: + return secrets.token_hex(4).upper() + + +def create_order_from_cart( + db: Session, + *, + email: str, + cart: dict[str, int], + customer_note: str = "", +) -> Order: + if not cart: + raise OrderError("Корзина пуста") + + email = email.strip().lower() + if "@" not in email or "." not in email.split("@")[-1]: + raise OrderError("Укажите корректный email") + + product_ids = [int(pid) for pid in cart.keys()] + products = { + p.id: p + for p in db.scalars( + select(Product).where(Product.id.in_(product_ids), Product.is_active.is_(True)) + ).all() + } + + order = Order( + public_id=make_public_id(), + email=email, + status="pending", + total=Decimal("0.00"), + customer_note=customer_note.strip()[:500], + ) + db.add(order) + db.flush() + + total = Decimal("0.00") + for pid_str, qty in cart.items(): + product = products.get(int(pid_str)) + if not product: + raise OrderError("Товар недоступен") + qty = int(qty) + if qty <= 0: + continue + stock = available_stock(db, product.id) + if stock < qty: + raise OrderError( + f"Недостаточно ключей для «{product.title}» (в наличии {stock})" + ) + line = product.price * qty + total += line + db.add( + OrderItem( + order_id=order.id, + product_id=product.id, + title=product.title, + unit_price=product.price, + quantity=qty, + ) + ) + + if total <= 0: + raise OrderError("Нечего оформлять") + + order.total = total + db.commit() + db.refresh(order) + return order + + +def pay_and_deliver(db: Session, order: Order) -> Order: + """Demo payment: mark paid and assign unused digital keys.""" + if order.status == "delivered": + return order + if order.status == "cancelled": + raise OrderError("Заказ отменён") + + now = datetime.now(timezone.utc) + items = db.scalars(select(OrderItem).where(OrderItem.order_id == order.id)).all() + for item in items: + keys = db.scalars( + select(ProductKey) + .where( + ProductKey.product_id == item.product_id, + ProductKey.is_sold.is_(False), + ) + .order_by(ProductKey.id) + .limit(item.quantity) + .with_for_update() + ).all() + if len(keys) < item.quantity: + raise OrderError(f"Не хватает ключей для «{item.title}»") + + codes: list[str] = [] + for key in keys: + key.is_sold = True + key.order_id = order.id + key.sold_at = now + codes.append(key.code) + item.delivered_codes = "\n".join(codes) + + order.status = "delivered" + order.paid_at = now + order.delivered_at = now + db.commit() + db.refresh(order) + return order diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/admin.py b/app/routers/admin.py new file mode 100644 index 0000000..24a3d5d --- /dev/null +++ b/app/routers/admin.py @@ -0,0 +1,414 @@ +from __future__ import annotations + +from decimal import Decimal, InvalidOperation + +from fastapi import APIRouter, Depends, Form, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy import delete, func, select +from sqlalchemy.orm import Session + +from app.auth import is_admin, login_admin, logout_admin +from app.cart import available_stock +from app.config import get_settings +from app.database import get_db +from app.models import Category, Order, Product, ProductKey + +router = APIRouter(prefix="/admin") +settings = get_settings() + + +def setup(templates: Jinja2Templates) -> None: + router.templates = templates # type: ignore[attr-defined] + + +def admin_ctx(request: Request, **kwargs): + data = { + "request": request, + "app_name": settings.app_name, + "admin_user": request.session.get("admin_user", "admin"), + } + data.update(kwargs) + return data + + +def guard(request: Request): + if not is_admin(request): + return RedirectResponse("/admin/login", status_code=303) + return None + + +@router.get("/login", response_class=HTMLResponse) +def admin_login_page(request: Request): + if is_admin(request): + return RedirectResponse("/admin", status_code=303) + return router.templates.TemplateResponse( # type: ignore[attr-defined] + request, + "admin/login.html", + admin_ctx(request, error=None), + ) + + +@router.post("/login", response_class=HTMLResponse) +def admin_login_submit( + request: Request, + username: str = Form(...), + password: str = Form(...), +): + if login_admin(request, username.strip(), password): + return RedirectResponse("/admin", status_code=303) + return router.templates.TemplateResponse( # type: ignore[attr-defined] + request, + "admin/login.html", + admin_ctx(request, error="Неверный логин или пароль"), + status_code=401, + ) + + +@router.post("/logout") +def admin_logout(request: Request): + logout_admin(request) + return RedirectResponse("/admin/login", status_code=303) + + +@router.get("", response_class=HTMLResponse) +@router.get("/", response_class=HTMLResponse) +def dashboard(request: Request, db: Session = Depends(get_db)): + if (redir := guard(request)): + return redir + + products_count = db.scalar(select(func.count()).select_from(Product)) or 0 + keys_free = ( + db.scalar( + select(func.count()) + .select_from(ProductKey) + .where(ProductKey.is_sold.is_(False)) + ) + or 0 + ) + orders_count = db.scalar(select(func.count()).select_from(Order)) or 0 + revenue = ( + db.scalar( + select(func.coalesce(func.sum(Order.total), 0)).where( + Order.status == "delivered" + ) + ) + or 0 + ) + recent_orders = db.scalars(select(Order).order_by(Order.id.desc()).limit(8)).all() + + return router.templates.TemplateResponse( # type: ignore[attr-defined] + request, + "admin/dashboard.html", + admin_ctx( + request, + products_count=products_count, + keys_free=keys_free, + orders_count=orders_count, + revenue=revenue, + recent_orders=recent_orders, + ), + ) + + +@router.get("/products", response_class=HTMLResponse) +def products_list(request: Request, db: Session = Depends(get_db)): + if (redir := guard(request)): + return redir + products = db.scalars(select(Product).order_by(Product.id.desc())).all() + stock_map = {p.id: available_stock(db, p.id) for p in products} + return router.templates.TemplateResponse( # type: ignore[attr-defined] + request, + "admin/products.html", + admin_ctx(request, products=products, stock_map=stock_map), + ) + + +@router.get("/products/new", response_class=HTMLResponse) +def product_new(request: Request, db: Session = Depends(get_db)): + if (redir := guard(request)): + return redir + categories = db.scalars(select(Category).order_by(Category.sort_order)).all() + return router.templates.TemplateResponse( # type: ignore[attr-defined] + request, + "admin/product_form.html", + admin_ctx( + request, + product=None, + categories=categories, + error=None, + form={}, + ), + ) + + +@router.post("/products/new", response_class=HTMLResponse) +def product_create( + request: Request, + db: Session = Depends(get_db), + title: str = Form(...), + slug: str = Form(...), + short_description: str = Form(...), + description: str = Form(""), + price: str = Form(...), + category_slug: str = Form(...), + image_gradient: str = Form("mint"), + delivery_note: str = Form("Мгновенная выдача"), + is_featured: str | None = Form(None), + is_active: str | None = Form(None), +): + if (redir := guard(request)): + return redir + categories = db.scalars(select(Category).order_by(Category.sort_order)).all() + form = { + "title": title, + "slug": slug, + "short_description": short_description, + "description": description, + "price": price, + "category_slug": category_slug, + "image_gradient": image_gradient, + "delivery_note": delivery_note, + "is_featured": bool(is_featured), + "is_active": bool(is_active) if is_active is not None else True, + } + try: + price_val = Decimal(price.replace(",", ".").replace(" ", "")) + except (InvalidOperation, AttributeError): + return router.templates.TemplateResponse( # type: ignore[attr-defined] + request, + "admin/product_form.html", + admin_ctx( + request, + product=None, + categories=categories, + error="Некорректная цена", + form=form, + ), + status_code=400, + ) + + slug_clean = slug.strip().lower().replace(" ", "-") + exists = db.scalar(select(Product.id).where(Product.slug == slug_clean)) + if exists: + return router.templates.TemplateResponse( # type: ignore[attr-defined] + request, + "admin/product_form.html", + admin_ctx( + request, + product=None, + categories=categories, + error="Slug уже занят", + form=form, + ), + status_code=400, + ) + + product = Product( + title=title.strip(), + slug=slug_clean, + short_description=short_description.strip()[:300], + description=description.strip(), + price=price_val, + category_slug=category_slug, + image_gradient=image_gradient, + delivery_note=delivery_note.strip()[:200], + is_featured=bool(is_featured), + is_active=bool(is_active), + ) + db.add(product) + db.commit() + return RedirectResponse(f"/admin/products/{product.id}/keys", status_code=303) + + +@router.get("/products/{product_id}/edit", response_class=HTMLResponse) +def product_edit(product_id: int, request: Request, db: Session = Depends(get_db)): + if (redir := guard(request)): + return redir + product = db.get(Product, product_id) + if not product: + return RedirectResponse("/admin/products", status_code=303) + categories = db.scalars(select(Category).order_by(Category.sort_order)).all() + return router.templates.TemplateResponse( # type: ignore[attr-defined] + request, + "admin/product_form.html", + admin_ctx( + request, + product=product, + categories=categories, + error=None, + form={}, + ), + ) + + +@router.post("/products/{product_id}/edit", response_class=HTMLResponse) +def product_update( + product_id: int, + request: Request, + db: Session = Depends(get_db), + title: str = Form(...), + slug: str = Form(...), + short_description: str = Form(...), + description: str = Form(""), + price: str = Form(...), + category_slug: str = Form(...), + image_gradient: str = Form("mint"), + delivery_note: str = Form("Мгновенная выдача"), + is_featured: str | None = Form(None), + is_active: str | None = Form(None), +): + if (redir := guard(request)): + return redir + product = db.get(Product, product_id) + if not product: + return RedirectResponse("/admin/products", status_code=303) + categories = db.scalars(select(Category).order_by(Category.sort_order)).all() + + try: + price_val = Decimal(price.replace(",", ".").replace(" ", "")) + except (InvalidOperation, AttributeError): + return router.templates.TemplateResponse( # type: ignore[attr-defined] + request, + "admin/product_form.html", + admin_ctx( + request, + product=product, + categories=categories, + error="Некорректная цена", + form={}, + ), + status_code=400, + ) + + slug_clean = slug.strip().lower().replace(" ", "-") + clash = db.scalar( + select(Product.id).where(Product.slug == slug_clean, Product.id != product.id) + ) + if clash: + return router.templates.TemplateResponse( # type: ignore[attr-defined] + request, + "admin/product_form.html", + admin_ctx( + request, + product=product, + categories=categories, + error="Slug уже занят", + form={}, + ), + status_code=400, + ) + + product.title = title.strip() + product.slug = slug_clean + product.short_description = short_description.strip()[:300] + product.description = description.strip() + product.price = price_val + product.category_slug = category_slug + product.image_gradient = image_gradient + product.delivery_note = delivery_note.strip()[:200] + product.is_featured = bool(is_featured) + product.is_active = bool(is_active) + db.commit() + return RedirectResponse("/admin/products", status_code=303) + + +@router.post("/products/{product_id}/delete") +def product_delete(product_id: int, request: Request, db: Session = Depends(get_db)): + if (redir := guard(request)): + return redir + product = db.get(Product, product_id) + if product: + db.execute(delete(ProductKey).where(ProductKey.product_id == product_id)) + db.delete(product) + db.commit() + return RedirectResponse("/admin/products", status_code=303) + + +@router.get("/products/{product_id}/keys", response_class=HTMLResponse) +def product_keys(product_id: int, request: Request, db: Session = Depends(get_db)): + if (redir := guard(request)): + return redir + product = db.get(Product, product_id) + if not product: + return RedirectResponse("/admin/products", status_code=303) + keys = db.scalars( + select(ProductKey) + .where(ProductKey.product_id == product_id) + .order_by(ProductKey.is_sold, ProductKey.id.desc()) + ).all() + return router.templates.TemplateResponse( # type: ignore[attr-defined] + request, + "admin/keys.html", + admin_ctx( + request, + product=product, + keys=keys, + stock=available_stock(db, product_id), + message=None, + ), + ) + + +@router.post("/products/{product_id}/keys", response_class=HTMLResponse) +def product_keys_add( + product_id: int, + request: Request, + codes: str = Form(...), + db: Session = Depends(get_db), +): + if (redir := guard(request)): + return redir + product = db.get(Product, product_id) + if not product: + return RedirectResponse("/admin/products", status_code=303) + + lines = [line.strip() for line in codes.splitlines() if line.strip()] + added = 0 + for code in lines: + db.add(ProductKey(product_id=product_id, code=code)) + added += 1 + db.commit() + + keys = db.scalars( + select(ProductKey) + .where(ProductKey.product_id == product_id) + .order_by(ProductKey.is_sold, ProductKey.id.desc()) + ).all() + return router.templates.TemplateResponse( # type: ignore[attr-defined] + request, + "admin/keys.html", + admin_ctx( + request, + product=product, + keys=keys, + stock=available_stock(db, product_id), + message=f"Добавлено ключей: {added}", + ), + ) + + +@router.get("/orders", response_class=HTMLResponse) +def orders_list(request: Request, db: Session = Depends(get_db)): + if (redir := guard(request)): + return redir + orders = db.scalars(select(Order).order_by(Order.id.desc()).limit(100)).all() + return router.templates.TemplateResponse( # type: ignore[attr-defined] + request, + "admin/orders.html", + admin_ctx(request, orders=orders), + ) + + +@router.get("/orders/{order_id}", response_class=HTMLResponse) +def order_detail(order_id: int, request: Request, db: Session = Depends(get_db)): + if (redir := guard(request)): + return redir + order = db.get(Order, order_id) + if not order: + return RedirectResponse("/admin/orders", status_code=303) + return router.templates.TemplateResponse( # type: ignore[attr-defined] + request, + "admin/order_detail.html", + admin_ctx(request, order=order), + ) diff --git a/app/routers/shop.py b/app/routers/shop.py new file mode 100644 index 0000000..29621fb --- /dev/null +++ b/app/routers/shop.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Form, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.cart import ( + available_stock, + build_cart_items, + cart_count, + get_cart_map, + save_cart, +) +from app.config import get_settings +from app.database import get_db +from app.models import Category, Order, Product +from app.orders import OrderError, create_order_from_cart, pay_and_deliver + +router = APIRouter() +settings = get_settings() + + +def setup(templates: Jinja2Templates) -> None: + router.templates = templates # type: ignore[attr-defined] + + +def ctx(request: Request, **kwargs): + data = { + "request": request, + "app_name": settings.app_name, + "cart_count": cart_count(request), + } + data.update(kwargs) + return data + + +@router.get("/", response_class=HTMLResponse) +def home(request: Request, db: Session = Depends(get_db)): + categories = db.scalars( + select(Category).order_by(Category.sort_order, Category.name) + ).all() + featured = db.scalars( + select(Product) + .where(Product.is_active.is_(True), Product.is_featured.is_(True)) + .order_by(Product.id) + .limit(4) + ).all() + products = db.scalars( + select(Product) + .where(Product.is_active.is_(True)) + .order_by(Product.is_featured.desc(), Product.id) + .limit(12) + ).all() + stock_map = { + p.id: available_stock(db, p.id) for p in [*featured, *products] + } + + return router.templates.TemplateResponse( # type: ignore[attr-defined] + request, + "index.html", + ctx( + request, + categories=categories, + featured=featured, + products=products, + stock_map=stock_map, + ), + ) + + +@router.get("/product/{slug}", response_class=HTMLResponse) +def product_page(slug: str, request: Request, db: Session = Depends(get_db)): + product = db.scalar( + select(Product).where(Product.slug == slug, Product.is_active.is_(True)) + ) + if not product: + return RedirectResponse("/", status_code=303) + stock = available_stock(db, product.id) + return router.templates.TemplateResponse( # type: ignore[attr-defined] + request, + "product.html", + ctx(request, product=product, stock=stock), + ) + + +@router.post("/cart/add/{product_id}") +def cart_add( + product_id: int, + request: Request, + quantity: int = Form(1), + db: Session = Depends(get_db), +): + product = db.scalar( + select(Product).where(Product.id == product_id, Product.is_active.is_(True)) + ) + if not product: + return RedirectResponse("/", status_code=303) + + qty = max(1, min(int(quantity or 1), 10)) + stock = available_stock(db, product.id) + cart = get_cart_map(request) + current = cart.get(str(product_id), 0) + cart[str(product_id)] = min(current + qty, max(stock, 0)) + if cart[str(product_id)] <= 0: + cart.pop(str(product_id), None) + save_cart(request, cart) + return RedirectResponse("/cart", status_code=303) + + +@router.get("/cart", response_class=HTMLResponse) +def cart_page(request: Request, db: Session = Depends(get_db)): + items, total = build_cart_items(db, request) + return router.templates.TemplateResponse( # type: ignore[attr-defined] + request, + "cart.html", + ctx(request, items=items, total=total), + ) + + +@router.post("/cart/update") +def cart_update( + request: Request, + product_id: int = Form(...), + quantity: int = Form(...), + db: Session = Depends(get_db), +): + cart = get_cart_map(request) + pid = str(product_id) + qty = int(quantity) + if qty <= 0: + cart.pop(pid, None) + else: + stock = available_stock(db, product_id) + cart[pid] = min(qty, max(stock, 0)) + if cart[pid] <= 0: + cart.pop(pid, None) + save_cart(request, cart) + return RedirectResponse("/cart", status_code=303) + + +@router.post("/cart/remove/{product_id}") +def cart_remove(product_id: int, request: Request): + cart = get_cart_map(request) + cart.pop(str(product_id), None) + save_cart(request, cart) + return RedirectResponse("/cart", status_code=303) + + +@router.get("/checkout", response_class=HTMLResponse) +def checkout_page(request: Request, db: Session = Depends(get_db)): + items, total = build_cart_items(db, request) + if not items: + return RedirectResponse("/cart", status_code=303) + return router.templates.TemplateResponse( # type: ignore[attr-defined] + request, + "checkout.html", + ctx(request, items=items, total=total, error=None), + ) + + +@router.post("/checkout", response_class=HTMLResponse) +def checkout_submit( + request: Request, + email: str = Form(...), + note: str = Form(""), + db: Session = Depends(get_db), +): + items, total = build_cart_items(db, request) + if not items: + return RedirectResponse("/cart", status_code=303) + + cart = get_cart_map(request) + try: + order = create_order_from_cart( + db, email=email, cart=cart, customer_note=note + ) + order = pay_and_deliver(db, order) + save_cart(request, {}) + return RedirectResponse(f"/order/{order.public_id}?email={order.email}", status_code=303) + except OrderError as exc: + return router.templates.TemplateResponse( # type: ignore[attr-defined] + request, + "checkout.html", + ctx(request, items=items, total=total, error=str(exc)), + status_code=400, + ) + + +@router.get("/order/{public_id}", response_class=HTMLResponse) +def order_page( + public_id: str, + request: Request, + email: str = "", + db: Session = Depends(get_db), +): + order = db.scalar(select(Order).where(Order.public_id == public_id.upper())) + if not order: + return RedirectResponse("/", status_code=303) + if email and email.strip().lower() != order.email: + return router.templates.TemplateResponse( # type: ignore[attr-defined] + request, + "order_locked.html", + ctx(request, public_id=public_id, error="Email не совпадает с заказом"), + ) + if not email: + return router.templates.TemplateResponse( # type: ignore[attr-defined] + request, + "order_locked.html", + ctx(request, public_id=public_id, error=None), + ) + + return router.templates.TemplateResponse( # type: ignore[attr-defined] + request, + "order.html", + ctx(request, order=order), + ) + + +@router.post("/order/{public_id}", response_class=HTMLResponse) +def order_unlock( + public_id: str, + request: Request, + email: str = Form(...), +): + return RedirectResponse( + f"/order/{public_id}?email={email.strip().lower()}", + status_code=303, + ) + + +@router.get("/lookup", response_class=HTMLResponse) +def lookup_page(request: Request): + return router.templates.TemplateResponse( # type: ignore[attr-defined] + request, + "lookup.html", + ctx(request, error=None), + ) + + +@router.post("/lookup") +def lookup_submit( + request: Request, + public_id: str = Form(...), + email: str = Form(...), + db: Session = Depends(get_db), +): + order = db.scalar(select(Order).where(Order.public_id == public_id.strip().upper())) + if not order or order.email != email.strip().lower(): + return router.templates.TemplateResponse( # type: ignore[attr-defined] + request, + "lookup.html", + ctx(request, error="Заказ не найден. Проверьте номер и email."), + status_code=404, + ) + return RedirectResponse( + f"/order/{order.public_id}?email={order.email}", + status_code=303, + ) diff --git a/app/seed.py b/app/seed.py index f545404..316e10c 100644 --- a/app/seed.py +++ b/app/seed.py @@ -1,9 +1,10 @@ from decimal import Decimal +import secrets from sqlalchemy import select from sqlalchemy.orm import Session -from app.models import Category, Product +from app.models import Category, Product, ProductKey CATEGORIES = [ @@ -44,7 +45,7 @@ PRODUCTS = [ "category_slug": "giftcards", "image_gradient": "mint", "is_featured": True, - "delivery_note": "Код на email за 1–3 минуты", + "delivery_note": "Код сразу после оплаты", }, { "slug": "adobe-cc-1m", @@ -55,7 +56,7 @@ PRODUCTS = [ "category_slug": "software", "image_gradient": "coral", "is_featured": True, - "delivery_note": "Доступ в личном кабинете", + "delivery_note": "Доступ сразу после оплаты", }, { "slug": "cyberpunk-steam", @@ -77,7 +78,7 @@ PRODUCTS = [ "category_slug": "accounts", "image_gradient": "sand", "is_featured": True, - "delivery_note": "Активация до 15 минут", + "delivery_note": "Данные сразу после оплаты", }, { "slug": "windows-11-pro", @@ -88,7 +89,7 @@ PRODUCTS = [ "category_slug": "software", "image_gradient": "mint", "is_featured": False, - "delivery_note": "Ключ на email", + "delivery_note": "Ключ сразу после оплаты", }, { "slug": "playstation-1500", @@ -99,11 +100,15 @@ PRODUCTS = [ "category_slug": "giftcards", "image_gradient": "coral", "is_featured": False, - "delivery_note": "Код за несколько минут", + "delivery_note": "Код сразу после оплаты", }, ] +def _demo_code(prefix: str) -> str: + return f"{prefix}-{secrets.token_hex(4).upper()}-{secrets.token_hex(3).upper()}" + + def seed_if_empty(db: Session) -> None: has_categories = db.scalar(select(Category.id).limit(1)) is not None if not has_categories: @@ -114,5 +119,33 @@ def seed_if_empty(db: Session) -> None: has_products = db.scalar(select(Product.id).limit(1)) is not None if not has_products: for item in PRODUCTS: - db.add(Product(**item)) + product = Product(**item) + db.add(product) + db.flush() + for _ in range(8): + db.add( + ProductKey( + product_id=product.id, + code=_demo_code(product.slug[:8].upper()), + ) + ) + db.commit() + else: + # Ensure demo stock exists for products without keys + products = db.scalars(select(Product)).all() + for product in products: + has_keys = ( + db.scalar( + select(ProductKey.id).where(ProductKey.product_id == product.id).limit(1) + ) + is not None + ) + if not has_keys: + for _ in range(5): + db.add( + ProductKey( + product_id=product.id, + code=_demo_code(product.slug[:8].upper()), + ) + ) db.commit() diff --git a/app/static/css/style.css b/app/static/css/style.css index 512cf32..5c70296 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -87,6 +87,21 @@ main, color: var(--accent); } +.cart-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.4rem; + height: 1.4rem; + margin-left: 0.25rem; + padding: 0 0.35rem; + border-radius: 999px; + background: var(--accent-hot); + color: #fff; + font-size: 0.75rem; + font-weight: 700; +} + .hero { min-height: calc(100svh - 72px); display: grid; @@ -154,6 +169,12 @@ main, transform: translateY(-2px); } +.btn:disabled { + opacity: 0.45; + cursor: not-allowed; + transform: none; +} + .btn-primary { background: var(--ink); color: #f4faf6; @@ -172,6 +193,11 @@ main, font-size: 0.9rem; } +.btn-tiny { + padding: 0.4rem 0.75rem; + font-size: 0.85rem; +} + .hero-visual { position: relative; min-height: min(62vh, 560px); @@ -181,7 +207,6 @@ main, .hero-plane { position: absolute; inset: 0; - border-radius: 0; background: linear-gradient(135deg, rgba(15, 122, 76, 0.85), rgba(18, 32, 26, 0.92)), repeating-linear-gradient( @@ -216,17 +241,23 @@ main, transform: translateX(-50%); } -section { +section, +.page-section { max-width: 1200px; margin: 0 auto; padding: clamp(2.5rem, 6vw, 5rem) clamp(1.2rem, 4vw, 3rem); } +.page-section.narrow { + max-width: 640px; +} + .section-head { margin-bottom: 1.8rem; } -.section-head h2 { +.section-head h2, +.section-head h3 { margin: 0 0 0.35rem; font-family: var(--font-display); font-size: clamp(1.8rem, 3vw, 2.4rem); @@ -296,10 +327,15 @@ section { } .product-media { + display: block; min-height: 140px; background: linear-gradient(135deg, #0f7a4c, #1b4332); } +.product-media.tall { + min-height: 360px; +} + .gradient-mint .product-media { background: linear-gradient(135deg, #2d6a4f, #95d5b2); } @@ -331,6 +367,11 @@ section { letter-spacing: -0.02em; } +.product-body h3 a { + color: inherit; + text-decoration: none; +} + .product-body p { margin: 0; color: var(--ink-soft); @@ -360,6 +401,10 @@ section { font-size: 1.2rem; } +.price.big { + font-size: 1.6rem; +} + .steps { list-style: none; margin: 0; @@ -416,20 +461,6 @@ section { font-size: 0.9rem; } -.toast { - position: fixed; - left: 50%; - bottom: 1.4rem; - transform: translateX(-50%); - z-index: 20; - padding: 0.85rem 1.2rem; - border-radius: 999px; - background: var(--ink); - color: #f4faf6; - font-weight: 600; - box-shadow: var(--shadow); -} - .reveal { opacity: 0; transform: translateY(18px); @@ -446,6 +477,280 @@ section { color: var(--ink-soft); } +.back-link { + display: inline-block; + margin-bottom: 1.2rem; + color: var(--ink-soft); + text-decoration: none; + font-weight: 600; +} + +.product-layout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; + align-items: start; +} + +.product-info h1 { + margin: 0 0 0.8rem; + font-family: var(--font-display); + font-size: clamp(1.8rem, 4vw, 2.6rem); + letter-spacing: -0.03em; +} + +.lead { + font-size: 1.05rem; + color: var(--ink-soft); +} + +.stock-line { + font-weight: 600; +} + +.buy-row { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: center; + margin-top: 1.5rem; +} + +.inline-form { + display: inline-flex; + gap: 0.5rem; + align-items: center; +} + +.qty-label { + display: inline-flex; + gap: 0.4rem; + align-items: center; + font-size: 0.9rem; + font-weight: 600; +} + +input[type="number"], +input[type="email"], +input[type="text"], +input[type="password"], +textarea, +select { + width: 100%; + border: 1px solid var(--line); + border-radius: 12px; + padding: 0.7rem 0.85rem; + font: inherit; + background: rgba(255, 255, 255, 0.7); + color: var(--ink); +} + +.inline-form input[type="number"] { + width: 4.5rem; +} + +.stack-form { + display: grid; + gap: 1rem; +} + +.stack-form label { + display: grid; + gap: 0.4rem; + font-weight: 600; +} + +.stack-form .check { + display: flex; + align-items: center; + gap: 0.55rem; + font-weight: 600; +} + +.stack-form .check input { + width: auto; +} + +.table-wrap { + overflow-x: auto; +} + +.data-table { + width: 100%; + border-collapse: collapse; + background: var(--surface); +} + +.data-table th, +.data-table td { + text-align: left; + padding: 0.85rem 0.75rem; + border-bottom: 1px solid var(--line); + vertical-align: top; +} + +.data-table th { + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--ink-soft); +} + +.muted { + color: var(--ink-soft); + font-size: 0.9rem; +} + +.cart-summary { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 1rem; + margin-top: 1.5rem; +} + +.checkout-list { + list-style: none; + margin: 0 0 1.2rem; + padding: 0; +} + +.checkout-list li { + display: flex; + justify-content: space-between; + gap: 1rem; + padding: 0.7rem 0; + border-bottom: 1px solid var(--line); +} + +.flash { + padding: 0.85rem 1rem; + border-radius: 12px; + font-weight: 600; +} + +.flash.error { + background: rgba(232, 93, 4, 0.12); + color: #9a3412; +} + +.flash.ok { + background: rgba(15, 122, 76, 0.12); + color: var(--accent); +} + +.order-item { + margin: 1.2rem 0; + padding: 1rem 0; + border-top: 1px solid var(--line); +} + +.codes pre, +pre.codes { + margin: 0.5rem 0 0; + padding: 0.9rem 1rem; + border-radius: 12px; + background: #12201a; + color: #e8efe9; + overflow-x: auto; + font-size: 0.95rem; +} + +.admin-body { + background: + radial-gradient(900px 500px at 0% 0%, #c8e6d3 0%, transparent 50%), + linear-gradient(165deg, #e8efe9, #d5e2d8); +} + +.admin-shell { + display: grid; + grid-template-columns: 220px 1fr; + min-height: 100vh; +} + +.admin-nav { + display: flex; + flex-direction: column; + gap: 0.85rem; + padding: 1.4rem 1.1rem; + border-right: 1px solid var(--line); + background: rgba(255, 255, 255, 0.35); +} + +.admin-nav a { + color: var(--ink); + text-decoration: none; + font-weight: 600; +} + +.admin-main { + padding: 1.5rem clamp(1rem, 3vw, 2rem); +} + +.admin-login { + margin-top: 12vh; +} + +.admin-stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1rem; + margin-bottom: 2rem; +} + +.stat { + padding: 1rem; + border-top: 2px solid var(--ink); + background: var(--surface); +} + +.stat-label { + display: block; + color: var(--ink-soft); + font-size: 0.85rem; + margin-bottom: 0.35rem; +} + +.stat strong { + font-family: var(--font-display); + font-size: 1.5rem; +} + +.row-head { + display: flex; + justify-content: space-between; + align-items: end; + gap: 1rem; +} + +.section-head.compact { + margin-top: 1rem; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + align-items: center; +} + +.actions a, +.linkish { + color: var(--accent); + background: none; + border: 0; + padding: 0; + font: inherit; + font-weight: 600; + cursor: pointer; + text-decoration: none; +} + +.logout-form { + margin-top: auto; +} + @keyframes rise { from { opacity: 0; @@ -473,8 +778,14 @@ section { } @media (max-width: 960px) { - .hero { + .hero, + .product-layout, + .admin-shell, + .admin-stats { grid-template-columns: 1fr; + } + + .hero { min-height: auto; } @@ -489,6 +800,11 @@ section { .steps { grid-template-columns: 1fr 1fr; } + + .admin-nav { + border-right: 0; + border-bottom: 1px solid var(--line); + } } @media (max-width: 720px) { diff --git a/app/static/js/main.js b/app/static/js/main.js index 35bbeb9..9a8616b 100644 --- a/app/static/js/main.js +++ b/app/static/js/main.js @@ -1,24 +1,4 @@ (() => { - const toast = document.getElementById("toast"); - let toastTimer; - - const showToast = (message) => { - if (!toast) return; - toast.hidden = false; - toast.textContent = message; - clearTimeout(toastTimer); - toastTimer = setTimeout(() => { - toast.hidden = true; - }, 2600); - }; - - document.querySelectorAll("[data-buy]").forEach((button) => { - button.addEventListener("click", () => { - const slug = button.getAttribute("data-buy"); - showToast(`«${slug}» — оплата скоро будет подключена`); - }); - }); - const reveals = document.querySelectorAll(".reveal"); if ("IntersectionObserver" in window) { const observer = new IntersectionObserver( diff --git a/app/templates/admin/base.html b/app/templates/admin/base.html new file mode 100644 index 0000000..5158590 --- /dev/null +++ b/app/templates/admin/base.html @@ -0,0 +1,32 @@ + + +
+ + +Сводка по магазину
+| ID | +Сумма | +Статус | +|
|---|---|---|---|
| {{ order.public_id }} | +{{ order.email }} | +{{ order.total | rub }} | +{{ order.status }} | +
| Заказов пока нет | |||
Свободно: {{ stock }} шт.
+{{ message }}
+{% endif %} + + + +| Код | +Статус | +Order | +
|---|---|---|
{{ key.code }} |
+ {% if key.is_sold %}sold{% else %}free{% endif %} | +{% if key.order_id %}#{{ key.order_id }}{% else %}—{% endif %} | +
| Ключей пока нет | ||
Вход для управления магазином
+ {% if error %} +{{ error }}
+ {% endif %} + +{{ order.email }} · {{ order.status }} · {{ order.total | rub }}
+Комментарий: {{ order.customer_note }}
+{% endif %} + +{% for item in order.items %} +{{ item.unit_price | rub }}
+ {% if item.delivered_codes %} +{{ item.delivered_codes }}
+ {% endif %}
+Последние 100 заказов
+| Номер | +Сумма | +Статус | +Дата | +|
|---|---|---|---|---|
| {{ order.public_id }} | +{{ order.email }} | +{{ order.total | rub }} | +{{ order.status }} | +{{ order.created_at }} | +
| Заказов нет | ||||
{{ error }}
+{% endif %} + + +{% endblock %} diff --git a/app/templates/admin/products.html b/app/templates/admin/products.html new file mode 100644 index 0000000..1381dd7 --- /dev/null +++ b/app/templates/admin/products.html @@ -0,0 +1,48 @@ +{% extends "admin/base.html" %} +{% block title %}Товары — {{ app_name }}{% endblock %} + +{% block content %} +Каталог и остатки ключей
+| Название | +Цена | +Остаток | +Статус | ++ |
|---|---|---|---|---|
|
+ {{ product.title }}
+ {{ product.slug }}
+ |
+ {{ product.price | rub }} | +{{ stock_map.get(product.id, 0) }} | +{% if product.is_active %}active{% else %}hidden{% endif %} | ++ Изменить + Ключи + + | +
| Товаров нет | ||||