Merge pull request #13 from Danieli4/codex/add-offline-event-management-feature-euea53

Add admin store management with item images
This commit is contained in:
Danil Gryaznev 2025-09-30 22:46:35 -06:00 committed by GitHub
commit e1a1dcddf5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 506 additions and 5 deletions

View File

@ -0,0 +1,25 @@
"""Add image url to store items"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "20241014_0010"
down_revision = "20241012_0009"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Добавляем колонку с изображением товара."""
op.add_column("store_items", sa.Column("image_url", sa.String(length=255), nullable=True))
def downgrade() -> None:
"""Удаляем колонку с изображением товара."""
op.drop_column("store_items", "image_url")

View File

@ -19,6 +19,7 @@ from app.models.mission import (
SubmissionStatus,
)
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
from app.models.store import StoreItem
from app.models.user import Competency, User, UserRole
from app.schemas.artifact import ArtifactCreate, ArtifactRead, ArtifactUpdate
from app.schemas.branch import BranchCreate, BranchMissionRead, BranchRead, BranchUpdate
@ -38,6 +39,8 @@ from app.schemas.rank import (
RankUpdate,
)
from app.schemas.user import CompetencyBase
from app.schemas.store import StoreItemCreate, StoreItemRead, StoreItemUpdate
from app.services.mission import approve_submission, registration_is_open, reject_submission
from app.schemas.admin_stats import AdminDashboardStats, BranchCompletionStat, SubmissionStats
@ -138,6 +141,15 @@ def _branch_to_read(branch: Branch) -> BranchRead:
)
def _sanitize_optional(value: str | None) -> str | None:
"""Обрезаем пробелы и заменяем пустые строки на None."""
if value is None:
return None
stripped = value.strip()
return stripped or None
def _load_rank(db: Session, rank_id: int) -> Rank:
"""Загружаем ранг с зависимостями."""
@ -176,6 +188,99 @@ def admin_missions(*, db: Session = Depends(get_db), current_user=Depends(requir
return [MissionBase.model_validate(mission) for mission in missions]
@router.get("/store/items", response_model=list[StoreItemRead], summary="Товары магазина (HR)")
def admin_store_items(
*, db: Session = Depends(get_db), current_user=Depends(require_hr)
) -> list[StoreItemRead]:
"""Возвращаем товары магазина для панели HR."""
items = db.query(StoreItem).order_by(StoreItem.name).all()
return [StoreItemRead.model_validate(item) for item in items]
@router.post(
"/store/items",
response_model=StoreItemRead,
status_code=status.HTTP_201_CREATED,
summary="Создать товар",
)
def admin_store_create(
item_in: StoreItemCreate,
*,
db: Session = Depends(get_db),
current_user=Depends(require_hr),
) -> StoreItemRead:
"""Создаём новый товар в магазине."""
name = item_in.name.strip()
description = item_in.description.strip()
if not name or not description:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Название и описание не могут быть пустыми",
)
item = StoreItem(
name=name,
description=description,
cost_mana=item_in.cost_mana,
stock=item_in.stock,
image_url=_sanitize_optional(item_in.image_url),
)
db.add(item)
db.commit()
db.refresh(item)
return StoreItemRead.model_validate(item)
@router.patch(
"/store/items/{item_id}",
response_model=StoreItemRead,
summary="Обновить товар",
)
def admin_store_update(
item_id: int,
item_in: StoreItemUpdate,
*,
db: Session = Depends(get_db),
current_user=Depends(require_hr),
) -> StoreItemRead:
"""Редактируем существующий товар."""
item = db.query(StoreItem).filter(StoreItem.id == item_id).first()
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Товар не найден")
update_data = item_in.model_dump(exclude_unset=True)
if "name" in update_data and update_data["name"] is not None:
new_name = update_data["name"].strip()
if not new_name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Название не может быть пустым",
)
item.name = new_name
if "description" in update_data and update_data["description"] is not None:
new_description = update_data["description"].strip()
if not new_description:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Описание не может быть пустым",
)
item.description = new_description
if "cost_mana" in update_data and update_data["cost_mana"] is not None:
item.cost_mana = update_data["cost_mana"]
if "stock" in update_data and update_data["stock"] is not None:
item.stock = update_data["stock"]
if "image_url" in update_data:
item.image_url = _sanitize_optional(update_data["image_url"])
db.add(item)
db.commit()
db.refresh(item)
return StoreItemRead.model_validate(item)
@router.get("/missions/{mission_id}", response_model=MissionDetail, summary="Детали миссии")
def admin_mission_detail(
mission_id: int,

View File

@ -30,6 +30,7 @@ class StoreItem(Base, TimestampMixin):
description: Mapped[str] = mapped_column(Text, nullable=False)
cost_mana: Mapped[int] = mapped_column(Integer, nullable=False)
stock: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
image_url: Mapped[Optional[str]] = mapped_column(String(255))
orders: Mapped[List["Order"]] = relationship("Order", back_populates="item")

View File

@ -10,19 +10,41 @@ from pydantic import BaseModel
from app.models.store import OrderStatus
class StoreItemRead(BaseModel):
"""Товар магазина."""
class StoreItemBase(BaseModel):
"""Базовые поля товара магазина."""
id: int
name: str
description: str
cost_mana: int
stock: int
image_url: Optional[str] = None
class StoreItemRead(StoreItemBase):
"""Товар магазина для чтения."""
id: int
class Config:
from_attributes = True
class StoreItemCreate(StoreItemBase):
"""Запрос на создание товара."""
pass
class StoreItemUpdate(BaseModel):
"""Запрос на обновление товара."""
name: Optional[str] = None
description: Optional[str] = None
cost_mana: Optional[int] = None
stock: Optional[int] = None
image_url: Optional[str] = None
class OrderRead(BaseModel):
"""Информация о заказе."""

View File

@ -5,6 +5,7 @@ from __future__ import annotations
import base64
import mimetypes
import shutil
from pathlib import Path
from pathlib import Path
import mimetypes

View File

@ -0,0 +1,38 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360" role="img" aria-labelledby="shirtTitle shirtDesc">
<title id="shirtTitle">Футболка экипажа Алабуги</title>
<desc id="shirtDesc">Минималистичная иллюстрация футболки с надписью Алабуга и эмблемой миссии.</desc>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#0f172a" />
<stop offset="100%" stop-color="#1e293b" />
</linearGradient>
<linearGradient id="shirt" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#5c6bc0" />
<stop offset="100%" stop-color="#283593" />
</linearGradient>
</defs>
<rect width="640" height="360" fill="url(#bg)" />
<g transform="translate(160 40)">
<path
d="M60 20 L120 0 L180 20 L220 20 C240 20 248 38 244 60 L226 150 C220 200 200 240 160 260 L120 280 L80 260 C40 240 20 200 14 150 L-4 60 C-8 38 0 20 20 20 Z"
fill="url(#shirt)"
stroke="#c5cae9"
stroke-width="6"
stroke-linejoin="round"
/>
<rect x="72" y="92" width="136" height="46" rx="8" fill="#1e88e5" opacity="0.85" />
<text x="140" y="122" text-anchor="middle" font-size="32" font-weight="600" fill="#fff" font-family="'Montserrat', 'Arial', sans-serif">
АЛАБУГА
</text>
<circle cx="140" cy="190" r="42" fill="#0d47a1" stroke="#bbdefb" stroke-width="6" />
<text x="140" y="200" text-anchor="middle" font-size="26" font-weight="500" fill="#bbdefb" font-family="'Montserrat', 'Arial', sans-serif">
⚡42
</text>
</g>
<text x="60" y="320" fill="#e8eaf6" font-family="'Montserrat', 'Arial', sans-serif" font-size="32" font-weight="600">
Футболка экипажа
</text>
<text x="60" y="348" fill="#c5cae9" font-family="'Montserrat', 'Arial', sans-serif" font-size="20">
Лимитированная серия для выпускников миссии
</text>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360" role="img" aria-labelledby="title desc">
<title id="title">Экскурсия по Алабуге</title>
<desc id="desc">Стилизованный рисунок кампуса Алабуги с отмеченным маршрутом экскурсии.</desc>
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#3f51b5" />
<stop offset="100%" stop-color="#1a237e" />
</linearGradient>
<linearGradient id="sun" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#ffeb3b" />
<stop offset="100%" stop-color="#ff9800" />
</linearGradient>
<linearGradient id="path" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#ff8a80" />
<stop offset="100%" stop-color="#ff5252" />
</linearGradient>
</defs>
<rect width="640" height="360" fill="url(#sky)" />
<circle cx="520" cy="90" r="46" fill="url(#sun)" opacity="0.9" />
<g fill="none" stroke="#90caf9" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" opacity="0.75">
<path d="M80 260 L160 210 L220 240 L320 200 L420 220 L520 190" />
<path d="M100 180 L180 150 L260 170 L340 140 L420 160" />
</g>
<g fill="#e3f2fd" opacity="0.9">
<rect x="70" y="190" width="120" height="90" rx="12" />
<rect x="210" y="210" width="120" height="70" rx="10" />
<rect x="360" y="200" width="140" height="80" rx="14" />
</g>
<path d="M100 300 C220 280, 360 300, 520 250" stroke="url(#path)" stroke-width="12" fill="none" stroke-linecap="round" stroke-linejoin="round" />
<g fill="#fff" font-family="'Montserrat', 'Arial', sans-serif">
<text x="60" y="330" font-size="36" font-weight="600">Экскурсия по Алабуге</text>
<text x="60" y="360" font-size="20" opacity="0.85">Тур по производственным цехам и кампусу</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -3,6 +3,7 @@ import { AdminMissionManager } from '../../components/admin/AdminMissionManager'
import { AdminRankManager } from '../../components/admin/AdminRankManager';
import { AdminArtifactManager } from '../../components/admin/AdminArtifactManager';
import { AdminSubmissionCard } from '../../components/admin/AdminSubmissionCard';
import { AdminStoreManager } from '../../components/admin/AdminStoreManager';
import { apiFetch } from '../../lib/api';
import { requireRole } from '../../lib/auth/session';
@ -61,6 +62,15 @@ interface ArtifactSummary {
image_url?: string | null;
}
interface StoreItemSummary {
id: number;
name: string;
description: string;
cost_mana: number;
stock: number;
image_url: string | null;
}
interface SubmissionStats {
pending: number;
approved: number;
@ -85,14 +95,24 @@ export default async function AdminPage() {
// Админ-панель доступна только HR-сотрудникам; проверяем роль до загрузки данных.
const session = await requireRole('hr');
const [submissions, missions, branches, ranks, competencies, artifacts, stats] = await Promise.all([
const [
submissions,
missions,
branches,
ranks,
competencies,
artifacts,
stats,
storeItems,
] = await Promise.all([
apiFetch<Submission[]>('/api/admin/submissions', { authToken: session.token }),
apiFetch<MissionSummary[]>('/api/admin/missions', { authToken: session.token }),
apiFetch<BranchSummary[]>('/api/admin/branches', { authToken: session.token }),
apiFetch<RankSummary[]>('/api/admin/ranks', { authToken: session.token }),
apiFetch<CompetencySummary[]>('/api/admin/competencies', { authToken: session.token }),
apiFetch<ArtifactSummary[]>('/api/admin/artifacts', { authToken: session.token }),
apiFetch<AdminStats>('/api/admin/stats', { authToken: session.token })
apiFetch<AdminStats>('/api/admin/stats', { authToken: session.token }),
apiFetch<StoreItemSummary[]>('/api/admin/store/items', { authToken: session.token }),
]);
return (
@ -167,6 +187,7 @@ export default async function AdminPage() {
/>
<AdminRankManager token={session.token} ranks={ranks} missions={missions} competencies={competencies} />
<AdminArtifactManager token={session.token} artifacts={artifacts} />
<AdminStoreManager token={session.token} items={storeItems} />
</div>
</section>
);

View File

@ -8,6 +8,7 @@ interface StoreItem {
description: string;
cost_mana: number;
stock: number;
image_url: string | null;
}
async function fetchStore(token: string) {

View File

@ -10,6 +10,7 @@ type StoreItem = {
description: string;
cost_mana: number;
stock: number;
image_url: string | null;
};
const Card = styled.div`
@ -68,6 +69,19 @@ export function StoreItems({ items, token }: { items: StoreItem[]; token?: strin
<div className="grid">
{items.map((item) => (
<Card key={item.id}>
{item.image_url && (
<img
src={item.image_url}
alt={item.name}
style={{
width: '100%',
maxHeight: '180px',
borderRadius: '10px',
marginBottom: '1rem',
objectFit: 'cover',
}}
/>
)}
<h3 style={{ marginBottom: '0.5rem' }}>{item.name}</h3>
<p style={{ color: 'var(--text-muted)' }}>{item.description}</p>
<p style={{ marginTop: '1rem' }}>{item.cost_mana} · остаток {item.stock}</p>

View File

@ -0,0 +1,237 @@
'use client';
import { FormEvent, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { apiFetch } from '../../lib/api';
export type StoreItem = {
id: number;
name: string;
description: string;
cost_mana: number;
stock: number;
image_url: string | null;
};
interface Props {
token: string;
items: StoreItem[];
}
interface FormState {
name: string;
description: string;
cost_mana: number;
stock: number;
image_url: string;
}
const emptyForm: FormState = {
name: '',
description: '',
cost_mana: 0,
stock: 0,
image_url: '',
};
export function AdminStoreManager({ token, items }: Props) {
const router = useRouter();
const [selectedId, setSelectedId] = useState<number | 'new'>('new');
const [form, setForm] = useState<FormState>(emptyForm);
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const itemById = useMemo(() => new Map(items.map((item) => [item.id, item])), [items]);
const resetForm = () => {
setForm({ ...emptyForm });
setSelectedId('new');
};
const handleSelect = (id: number | 'new') => {
setStatus(null);
setError(null);
setSelectedId(id);
if (id === 'new') {
setForm({ ...emptyForm });
} else {
const item = itemById.get(id);
if (item) {
// Подставляем актуальные значения товара в форму.
setForm({
name: item.name,
description: item.description,
cost_mana: item.cost_mana,
stock: item.stock,
image_url: item.image_url ?? '',
});
}
}
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSaving(true);
setStatus(null);
setError(null);
try {
const payload = {
name: form.name.trim(),
description: form.description.trim(),
cost_mana: form.cost_mana,
stock: form.stock,
image_url: form.image_url.trim() === '' ? null : form.image_url.trim(),
};
if (!payload.name || !payload.description) {
throw new Error('Название и описание не должны быть пустыми.');
}
if (selectedId === 'new') {
await apiFetch('/api/admin/store/items', {
method: 'POST',
body: JSON.stringify(payload),
authToken: token,
});
setStatus('Товар добавлен. Мы обновили список, можно проверить карточку ниже.');
resetForm();
} else {
await apiFetch(`/api/admin/store/items/${selectedId}`, {
method: 'PATCH',
body: JSON.stringify(payload),
authToken: token,
});
setStatus('Изменения сохранены. Страница магазина обновится автоматически.');
}
router.refresh();
} catch (submitError) {
const message = submitError instanceof Error ? submitError.message : 'Не удалось сохранить товар.';
setError(message);
} finally {
setSaving(false);
}
};
return (
<div className="card" style={{ gridColumn: '1 / -1' }}>
<h3>Магазин</h3>
<p style={{ color: 'var(--text-muted)' }}>
Управляйте призами: загружайте изображения, задавайте стоимость в мане и поддерживайте актуальный остаток.
</p>
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<div style={{ minWidth: '260px' }}>
<label className="label" htmlFor="store-item-select">
Выберите товар
</label>
<select
id="store-item-select"
value={selectedId === 'new' ? 'new' : selectedId}
onChange={(event) => {
const value = event.target.value === 'new' ? 'new' : Number(event.target.value);
handleSelect(value);
}}
>
<option value="new">Добавить новый</option>
{items.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
<button type="button" className="secondary" onClick={resetForm} disabled={saving}>
Очистить форму
</button>
</div>
<form onSubmit={handleSubmit} style={{ marginTop: '1.5rem', display: 'grid', gap: '1rem' }}>
<div>
<label className="label" htmlFor="store-name">
Название
</label>
<input
id="store-name"
value={form.name}
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
placeholder="Например, экскурсия по кампусу"
/>
</div>
<div>
<label className="label" htmlFor="store-description">
Описание
</label>
<textarea
id="store-description"
value={form.description}
onChange={(event) => setForm((prev) => ({ ...prev, description: event.target.value }))}
rows={4}
placeholder="Коротко расскажите, что получит пилот"
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: '1rem' }}>
<div>
<label className="label" htmlFor="store-cost">
Стоимость ()
</label>
<input
id="store-cost"
type="number"
min={0}
value={form.cost_mana}
onChange={(event) => setForm((prev) => ({ ...prev, cost_mana: Number(event.target.value) }))}
/>
</div>
<div>
<label className="label" htmlFor="store-stock">
Остаток
</label>
<input
id="store-stock"
type="number"
min={0}
value={form.stock}
onChange={(event) => setForm((prev) => ({ ...prev, stock: Number(event.target.value) }))}
/>
</div>
</div>
<div>
<label className="label" htmlFor="store-image">
Ссылка на изображение
</label>
<input
id="store-image"
value={form.image_url}
onChange={(event) => setForm((prev) => ({ ...prev, image_url: event.target.value }))}
placeholder="Например, /store/excursion-alabuga.svg"
/>
<small style={{ color: 'var(--text-muted)' }}>
Изображение можно сохранить в public/store и указать относительный путь.
</small>
</div>
<button className="primary" type="submit" disabled={saving}>
{saving ? 'Сохраняем...' : selectedId === 'new' ? 'Добавить товар' : 'Сохранить изменения'}
</button>
</form>
{status && (
<p style={{ color: 'var(--accent-light)', marginTop: '1rem' }}>
{status}
</p>
)}
{error && (
<p style={{ color: 'var(--error)', marginTop: '1rem' }}>
{error}
</p>
)}
</div>
);
}

View File

@ -462,12 +462,14 @@ def seed() -> None:
description="Личный тур по цехам Алабуги",
cost_mana=200,
stock=5,
image_url="/store/excursion-alabuga.svg",
),
StoreItem(
name="Мерч экипажа",
description="Футболка с эмблемой миссии",
cost_mana=150,
stock=10,
image_url="/store/alabuga-crew-shirt.svg",
),
]
)