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, SubmissionStatus,
) )
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
from app.models.store import StoreItem
from app.models.user import Competency, User, UserRole from app.models.user import Competency, User, UserRole
from app.schemas.artifact import ArtifactCreate, ArtifactRead, ArtifactUpdate from app.schemas.artifact import ArtifactCreate, ArtifactRead, ArtifactUpdate
from app.schemas.branch import BranchCreate, BranchMissionRead, BranchRead, BranchUpdate from app.schemas.branch import BranchCreate, BranchMissionRead, BranchRead, BranchUpdate
@ -38,6 +39,8 @@ from app.schemas.rank import (
RankUpdate, RankUpdate,
) )
from app.schemas.user import CompetencyBase 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.services.mission import approve_submission, registration_is_open, reject_submission
from app.schemas.admin_stats import AdminDashboardStats, BranchCompletionStat, SubmissionStats 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: 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] 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="Детали миссии") @router.get("/missions/{mission_id}", response_model=MissionDetail, summary="Детали миссии")
def admin_mission_detail( def admin_mission_detail(
mission_id: int, mission_id: int,

View File

@ -30,6 +30,7 @@ class StoreItem(Base, TimestampMixin):
description: Mapped[str] = mapped_column(Text, nullable=False) description: Mapped[str] = mapped_column(Text, nullable=False)
cost_mana: Mapped[int] = mapped_column(Integer, nullable=False) cost_mana: Mapped[int] = mapped_column(Integer, nullable=False)
stock: Mapped[int] = mapped_column(Integer, default=0, 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") 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 from app.models.store import OrderStatus
class StoreItemRead(BaseModel): class StoreItemBase(BaseModel):
"""Товар магазина.""" """Базовые поля товара магазина."""
id: int
name: str name: str
description: str description: str
cost_mana: int cost_mana: int
stock: int stock: int
image_url: Optional[str] = None
class StoreItemRead(StoreItemBase):
"""Товар магазина для чтения."""
id: int
class Config: class Config:
from_attributes = True 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): class OrderRead(BaseModel):
"""Информация о заказе.""" """Информация о заказе."""

View File

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

View File

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

View File

@ -10,6 +10,7 @@ type StoreItem = {
description: string; description: string;
cost_mana: number; cost_mana: number;
stock: number; stock: number;
image_url: string | null;
}; };
const Card = styled.div` const Card = styled.div`
@ -68,6 +69,19 @@ export function StoreItems({ items, token }: { items: StoreItem[]; token?: strin
<div className="grid"> <div className="grid">
{items.map((item) => ( {items.map((item) => (
<Card key={item.id}> <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> <h3 style={{ marginBottom: '0.5rem' }}>{item.name}</h3>
<p style={{ color: 'var(--text-muted)' }}>{item.description}</p> <p style={{ color: 'var(--text-muted)' }}>{item.description}</p>
<p style={{ marginTop: '1rem' }}>{item.cost_mana} · остаток {item.stock}</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="Личный тур по цехам Алабуги", description="Личный тур по цехам Алабуги",
cost_mana=200, cost_mana=200,
stock=5, stock=5,
image_url="/store/excursion-alabuga.svg",
), ),
StoreItem( StoreItem(
name="Мерч экипажа", name="Мерч экипажа",
description="Футболка с эмблемой миссии", description="Футболка с эмблемой миссии",
cost_mana=150, cost_mana=150,
stock=10, stock=10,
image_url="/store/alabuga-crew-shirt.svg",
), ),
] ]
) )