Compare commits
10 Commits
2b6f2888ee
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b1490dec8 | |||
| 765da3c37f | |||
| 9f79daf796 | |||
| 58c390c768 | |||
| 72089d1b47 | |||
| 9cfe99ff7e | |||
| 2d8e80f258 | |||
| f78eacb1a5 | |||
| cf0df928b1 | |||
| 5c452c5c74 |
@@ -5,6 +5,7 @@ Revises: 028
|
|||||||
Create Date: 2025-01-09
|
Create Date: 2025-01-09
|
||||||
"""
|
"""
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
from sqlalchemy import inspect
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
@@ -14,7 +15,16 @@ branch_labels = None
|
|||||||
depends_on = None
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
def upgrade():
|
||||||
|
if table_exists('widget_tokens'):
|
||||||
|
return
|
||||||
|
|
||||||
op.create_table(
|
op.create_table(
|
||||||
'widget_tokens',
|
'widget_tokens',
|
||||||
sa.Column('id', sa.Integer(), nullable=False),
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
|||||||
28
backend/alembic/versions/030_merge_029_heads.py
Normal file
28
backend/alembic/versions/030_merge_029_heads.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""Merge 029 heads
|
||||||
|
|
||||||
|
Revision ID: 030_merge_029_heads
|
||||||
|
Revises: 029_add_tracked_time, 029_add_widget_tokens
|
||||||
|
Create Date: 2026-01-10
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '030_merge_029_heads'
|
||||||
|
down_revision: Union[str, Sequence[str]] = ('029_add_tracked_time', '029_add_widget_tokens')
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Merge migration - no changes needed
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Merge migration - no changes needed
|
||||||
|
pass
|
||||||
65
backend/alembic/versions/031_add_exiled_games.py
Normal file
65
backend/alembic/versions/031_add_exiled_games.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""Add exiled games and skip_exile consumable
|
||||||
|
|
||||||
|
Revision ID: 030
|
||||||
|
Revises: 029
|
||||||
|
Create Date: 2025-01-10
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = '031_add_exiled_games'
|
||||||
|
down_revision = '030_merge_029_heads'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Create exiled_games table if not exists
|
||||||
|
if not table_exists('exiled_games'):
|
||||||
|
op.create_table(
|
||||||
|
'exiled_games',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('participant_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('game_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('assignment_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('exiled_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column('exiled_by', sa.String(20), nullable=False),
|
||||||
|
sa.Column('reason', sa.String(500), nullable=True),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||||
|
sa.Column('unexiled_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('unexiled_by', sa.String(20), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.ForeignKeyConstraint(['participant_id'], ['participants.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['game_id'], ['games.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['assignment_id'], ['assignments.id'], ondelete='SET NULL'),
|
||||||
|
sa.UniqueConstraint('participant_id', 'game_id', name='unique_participant_game_exile'),
|
||||||
|
)
|
||||||
|
op.create_index('ix_exiled_games_participant_id', 'exiled_games', ['participant_id'])
|
||||||
|
op.create_index('ix_exiled_games_active', 'exiled_games', ['participant_id', 'is_active'])
|
||||||
|
|
||||||
|
# Add skip_exile consumable to shop if not exists
|
||||||
|
op.execute("""
|
||||||
|
INSERT INTO shop_items (item_type, code, name, description, price, rarity, asset_data, is_active, created_at)
|
||||||
|
SELECT 'consumable', 'skip_exile', 'Скип с изгнанием',
|
||||||
|
'Пропустить текущее задание без штрафа. Игра навсегда исключается из вашего пула и больше не выпадет.',
|
||||||
|
150, 'rare', '{"effect": "skip_exile", "icon": "x-circle"}', true, NOW()
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE code = 'skip_exile')
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# Remove skip_exile from shop
|
||||||
|
op.execute("DELETE FROM shop_items WHERE code = 'skip_exile'")
|
||||||
|
|
||||||
|
# Drop exiled_games table
|
||||||
|
op.drop_index('ix_exiled_games_active', table_name='exiled_games')
|
||||||
|
op.drop_index('ix_exiled_games_participant_id', table_name='exiled_games')
|
||||||
|
op.drop_table('exiled_games')
|
||||||
@@ -10,6 +10,7 @@ from app.models import (
|
|||||||
Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge, Game,
|
Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge, Game,
|
||||||
SwapRequest as SwapRequestModel, SwapRequestStatus, User,
|
SwapRequest as SwapRequestModel, SwapRequestStatus, User,
|
||||||
)
|
)
|
||||||
|
from app.models.bonus_assignment import BonusAssignment
|
||||||
from fastapi import UploadFile, File, Form
|
from fastapi import UploadFile, File, Form
|
||||||
|
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
@@ -275,6 +276,26 @@ async def stop_event(
|
|||||||
return MessageResponse(message="Event stopped")
|
return MessageResponse(message="Event stopped")
|
||||||
|
|
||||||
|
|
||||||
|
def build_assignment_info(assignment: Assignment) -> SwapRequestChallengeInfo:
|
||||||
|
"""Build SwapRequestChallengeInfo from assignment (challenge or playthrough)"""
|
||||||
|
if assignment.is_playthrough:
|
||||||
|
return SwapRequestChallengeInfo(
|
||||||
|
is_playthrough=True,
|
||||||
|
playthrough_description=assignment.game.playthrough_description,
|
||||||
|
playthrough_points=assignment.game.playthrough_points,
|
||||||
|
game_title=assignment.game.title,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return SwapRequestChallengeInfo(
|
||||||
|
is_playthrough=False,
|
||||||
|
title=assignment.challenge.title,
|
||||||
|
description=assignment.challenge.description,
|
||||||
|
points=assignment.challenge.points,
|
||||||
|
difficulty=assignment.challenge.difficulty,
|
||||||
|
game_title=assignment.challenge.game.title,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_swap_request_response(
|
def build_swap_request_response(
|
||||||
swap_req: SwapRequestModel,
|
swap_req: SwapRequestModel,
|
||||||
) -> SwapRequestResponse:
|
) -> SwapRequestResponse:
|
||||||
@@ -298,20 +319,8 @@ def build_swap_request_response(
|
|||||||
role=swap_req.to_participant.user.role,
|
role=swap_req.to_participant.user.role,
|
||||||
created_at=swap_req.to_participant.user.created_at,
|
created_at=swap_req.to_participant.user.created_at,
|
||||||
),
|
),
|
||||||
from_challenge=SwapRequestChallengeInfo(
|
from_challenge=build_assignment_info(swap_req.from_assignment),
|
||||||
title=swap_req.from_assignment.challenge.title,
|
to_challenge=build_assignment_info(swap_req.to_assignment),
|
||||||
description=swap_req.from_assignment.challenge.description,
|
|
||||||
points=swap_req.from_assignment.challenge.points,
|
|
||||||
difficulty=swap_req.from_assignment.challenge.difficulty,
|
|
||||||
game_title=swap_req.from_assignment.challenge.game.title,
|
|
||||||
),
|
|
||||||
to_challenge=SwapRequestChallengeInfo(
|
|
||||||
title=swap_req.to_assignment.challenge.title,
|
|
||||||
description=swap_req.to_assignment.challenge.description,
|
|
||||||
points=swap_req.to_assignment.challenge.points,
|
|
||||||
difficulty=swap_req.to_assignment.challenge.difficulty,
|
|
||||||
game_title=swap_req.to_assignment.challenge.game.title,
|
|
||||||
),
|
|
||||||
created_at=swap_req.created_at,
|
created_at=swap_req.created_at,
|
||||||
responded_at=swap_req.responded_at,
|
responded_at=swap_req.responded_at,
|
||||||
)
|
)
|
||||||
@@ -349,11 +358,12 @@ async def create_swap_request(
|
|||||||
if target.id == participant.id:
|
if target.id == participant.id:
|
||||||
raise HTTPException(status_code=400, detail="Cannot swap with yourself")
|
raise HTTPException(status_code=400, detail="Cannot swap with yourself")
|
||||||
|
|
||||||
# Get both active assignments
|
# Get both active assignments (with challenge.game or game for playthrough)
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Assignment)
|
select(Assignment)
|
||||||
.options(
|
.options(
|
||||||
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||||
|
selectinload(Assignment.game), # For playthrough
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
Assignment.participant_id == participant.id,
|
Assignment.participant_id == participant.id,
|
||||||
@@ -365,7 +375,8 @@ async def create_swap_request(
|
|||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Assignment)
|
select(Assignment)
|
||||||
.options(
|
.options(
|
||||||
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||||
|
selectinload(Assignment.game), # For playthrough
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
Assignment.participant_id == target.id,
|
Assignment.participant_id == target.id,
|
||||||
@@ -417,7 +428,7 @@ async def create_swap_request(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(swap_request)
|
await db.refresh(swap_request)
|
||||||
|
|
||||||
# Load relationships for response
|
# Load relationships for response (including game for playthrough)
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(SwapRequestModel)
|
select(SwapRequestModel)
|
||||||
.options(
|
.options(
|
||||||
@@ -426,9 +437,13 @@ async def create_swap_request(
|
|||||||
selectinload(SwapRequestModel.from_assignment)
|
selectinload(SwapRequestModel.from_assignment)
|
||||||
.selectinload(Assignment.challenge)
|
.selectinload(Assignment.challenge)
|
||||||
.selectinload(Challenge.game),
|
.selectinload(Challenge.game),
|
||||||
|
selectinload(SwapRequestModel.from_assignment)
|
||||||
|
.selectinload(Assignment.game),
|
||||||
selectinload(SwapRequestModel.to_assignment)
|
selectinload(SwapRequestModel.to_assignment)
|
||||||
.selectinload(Assignment.challenge)
|
.selectinload(Assignment.challenge)
|
||||||
.selectinload(Challenge.game),
|
.selectinload(Challenge.game),
|
||||||
|
selectinload(SwapRequestModel.to_assignment)
|
||||||
|
.selectinload(Assignment.game),
|
||||||
)
|
)
|
||||||
.where(SwapRequestModel.id == swap_request.id)
|
.where(SwapRequestModel.id == swap_request.id)
|
||||||
)
|
)
|
||||||
@@ -461,9 +476,13 @@ async def get_my_swap_requests(
|
|||||||
selectinload(SwapRequestModel.from_assignment)
|
selectinload(SwapRequestModel.from_assignment)
|
||||||
.selectinload(Assignment.challenge)
|
.selectinload(Assignment.challenge)
|
||||||
.selectinload(Challenge.game),
|
.selectinload(Challenge.game),
|
||||||
|
selectinload(SwapRequestModel.from_assignment)
|
||||||
|
.selectinload(Assignment.game),
|
||||||
selectinload(SwapRequestModel.to_assignment)
|
selectinload(SwapRequestModel.to_assignment)
|
||||||
.selectinload(Assignment.challenge)
|
.selectinload(Assignment.challenge)
|
||||||
.selectinload(Challenge.game),
|
.selectinload(Challenge.game),
|
||||||
|
selectinload(SwapRequestModel.to_assignment)
|
||||||
|
.selectinload(Assignment.game),
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
SwapRequestModel.event_id == event.id,
|
SwapRequestModel.event_id == event.id,
|
||||||
@@ -553,10 +572,39 @@ async def accept_swap_request(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
raise HTTPException(status_code=400, detail="One or both assignments are no longer active")
|
raise HTTPException(status_code=400, detail="One or both assignments are no longer active")
|
||||||
|
|
||||||
# Perform the swap
|
# Perform the swap (swap challenge_id, game_id, and is_playthrough)
|
||||||
from_challenge_id = from_assignment.challenge_id
|
from_challenge_id = from_assignment.challenge_id
|
||||||
|
from_game_id = from_assignment.game_id
|
||||||
|
from_is_playthrough = from_assignment.is_playthrough
|
||||||
|
|
||||||
from_assignment.challenge_id = to_assignment.challenge_id
|
from_assignment.challenge_id = to_assignment.challenge_id
|
||||||
|
from_assignment.game_id = to_assignment.game_id
|
||||||
|
from_assignment.is_playthrough = to_assignment.is_playthrough
|
||||||
|
|
||||||
to_assignment.challenge_id = from_challenge_id
|
to_assignment.challenge_id = from_challenge_id
|
||||||
|
to_assignment.game_id = from_game_id
|
||||||
|
to_assignment.is_playthrough = from_is_playthrough
|
||||||
|
|
||||||
|
# Swap bonus assignments between the two assignments
|
||||||
|
from sqlalchemy import update as sql_update
|
||||||
|
|
||||||
|
# Get bonus assignments for both
|
||||||
|
from_bonus_result = await db.execute(
|
||||||
|
select(BonusAssignment).where(BonusAssignment.main_assignment_id == from_assignment.id)
|
||||||
|
)
|
||||||
|
from_bonus_assignments = from_bonus_result.scalars().all()
|
||||||
|
|
||||||
|
to_bonus_result = await db.execute(
|
||||||
|
select(BonusAssignment).where(BonusAssignment.main_assignment_id == to_assignment.id)
|
||||||
|
)
|
||||||
|
to_bonus_assignments = to_bonus_result.scalars().all()
|
||||||
|
|
||||||
|
# Move bonus assignments: from -> to, to -> from
|
||||||
|
for bonus in from_bonus_assignments:
|
||||||
|
bonus.main_assignment_id = to_assignment.id
|
||||||
|
|
||||||
|
for bonus in to_bonus_assignments:
|
||||||
|
bonus.main_assignment_id = from_assignment.id
|
||||||
|
|
||||||
# Update request status
|
# Update request status
|
||||||
swap_request.status = SwapRequestStatus.ACCEPTED.value
|
swap_request.status = SwapRequestStatus.ACCEPTED.value
|
||||||
@@ -865,8 +913,10 @@ async def get_swap_candidates(
|
|||||||
if not event or event.type != EventType.SWAP.value:
|
if not event or event.type != EventType.SWAP.value:
|
||||||
raise HTTPException(status_code=400, detail="No active swap event")
|
raise HTTPException(status_code=400, detail="No active swap event")
|
||||||
|
|
||||||
# Get all participants except current user with active assignments
|
|
||||||
from app.models import Game
|
from app.models import Game
|
||||||
|
candidates = []
|
||||||
|
|
||||||
|
# Get challenge-based assignments
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Participant, Assignment, Challenge, Game)
|
select(Participant, Assignment, Challenge, Game)
|
||||||
.join(Assignment, Assignment.participant_id == Participant.id)
|
.join(Assignment, Assignment.participant_id == Participant.id)
|
||||||
@@ -877,12 +927,11 @@ async def get_swap_candidates(
|
|||||||
Participant.marathon_id == marathon_id,
|
Participant.marathon_id == marathon_id,
|
||||||
Participant.id != participant.id,
|
Participant.id != participant.id,
|
||||||
Assignment.status == AssignmentStatus.ACTIVE.value,
|
Assignment.status == AssignmentStatus.ACTIVE.value,
|
||||||
|
Assignment.is_playthrough == False,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
rows = result.all()
|
for p, assignment, challenge, game in result.all():
|
||||||
|
candidates.append(SwapCandidate(
|
||||||
return [
|
|
||||||
SwapCandidate(
|
|
||||||
participant_id=p.id,
|
participant_id=p.id,
|
||||||
user=UserPublic(
|
user=UserPublic(
|
||||||
id=p.user.id,
|
id=p.user.id,
|
||||||
@@ -892,14 +941,45 @@ async def get_swap_candidates(
|
|||||||
role=p.user.role,
|
role=p.user.role,
|
||||||
created_at=p.user.created_at,
|
created_at=p.user.created_at,
|
||||||
),
|
),
|
||||||
|
is_playthrough=False,
|
||||||
challenge_title=challenge.title,
|
challenge_title=challenge.title,
|
||||||
challenge_description=challenge.description,
|
challenge_description=challenge.description,
|
||||||
challenge_points=challenge.points,
|
challenge_points=challenge.points,
|
||||||
challenge_difficulty=challenge.difficulty,
|
challenge_difficulty=challenge.difficulty,
|
||||||
game_title=game.title,
|
game_title=game.title,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Get playthrough-based assignments
|
||||||
|
result = await db.execute(
|
||||||
|
select(Participant, Assignment, Game)
|
||||||
|
.join(Assignment, Assignment.participant_id == Participant.id)
|
||||||
|
.join(Game, Assignment.game_id == Game.id)
|
||||||
|
.options(selectinload(Participant.user))
|
||||||
|
.where(
|
||||||
|
Participant.marathon_id == marathon_id,
|
||||||
|
Participant.id != participant.id,
|
||||||
|
Assignment.status == AssignmentStatus.ACTIVE.value,
|
||||||
|
Assignment.is_playthrough == True,
|
||||||
)
|
)
|
||||||
for p, assignment, challenge, game in rows
|
)
|
||||||
]
|
for p, assignment, game in result.all():
|
||||||
|
candidates.append(SwapCandidate(
|
||||||
|
participant_id=p.id,
|
||||||
|
user=UserPublic(
|
||||||
|
id=p.user.id,
|
||||||
|
login=p.user.login,
|
||||||
|
nickname=p.user.nickname,
|
||||||
|
avatar_url=None,
|
||||||
|
role=p.user.role,
|
||||||
|
created_at=p.user.created_at,
|
||||||
|
),
|
||||||
|
is_playthrough=True,
|
||||||
|
playthrough_description=game.playthrough_description,
|
||||||
|
playthrough_points=game.playthrough_points,
|
||||||
|
game_title=game.title,
|
||||||
|
))
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
@router.get("/marathons/{marathon_id}/common-enemy-leaderboard", response_model=list[CommonEnemyLeaderboard])
|
@router.get("/marathons/{marathon_id}/common-enemy-leaderboard", response_model=list[CommonEnemyLeaderboard])
|
||||||
@@ -993,6 +1073,7 @@ def assignment_to_response(assignment: Assignment) -> AssignmentResponse:
|
|||||||
streak_at_completion=assignment.streak_at_completion,
|
streak_at_completion=assignment.streak_at_completion,
|
||||||
started_at=assignment.started_at,
|
started_at=assignment.started_at,
|
||||||
completed_at=assignment.completed_at,
|
completed_at=assignment.completed_at,
|
||||||
|
tracked_time_minutes=assignment.tracked_time_minutes,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Regular challenge assignment
|
# Regular challenge assignment
|
||||||
@@ -1026,6 +1107,7 @@ def assignment_to_response(assignment: Assignment) -> AssignmentResponse:
|
|||||||
streak_at_completion=assignment.streak_at_completion,
|
streak_at_completion=assignment.streak_at_completion,
|
||||||
started_at=assignment.started_at,
|
started_at=assignment.started_at,
|
||||||
completed_at=assignment.completed_at,
|
completed_at=assignment.completed_at,
|
||||||
|
tracked_time_minutes=assignment.tracked_time_minutes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ from app.api.deps import (
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, GameType,
|
Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, GameType,
|
||||||
Challenge, Activity, ActivityType, Assignment, AssignmentStatus, Participant, User
|
Challenge, Activity, ActivityType, Assignment, AssignmentStatus, Participant, User,
|
||||||
|
ExiledGame
|
||||||
)
|
)
|
||||||
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
|
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
|
||||||
from app.schemas.assignment import AvailableGamesCount
|
from app.schemas.assignment import AvailableGamesCount
|
||||||
@@ -519,9 +520,23 @@ async def get_available_games_for_participant(
|
|||||||
)
|
)
|
||||||
completed_challenge_ids = set(challenges_result.scalars().all())
|
completed_challenge_ids = set(challenges_result.scalars().all())
|
||||||
|
|
||||||
|
# Получаем изгнанные игры (is_active=True означает что игра изгнана)
|
||||||
|
exiled_result = await db.execute(
|
||||||
|
select(ExiledGame.game_id)
|
||||||
|
.where(
|
||||||
|
ExiledGame.participant_id == participant.id,
|
||||||
|
ExiledGame.is_active == True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
exiled_game_ids = set(exiled_result.scalars().all())
|
||||||
|
|
||||||
# Фильтруем доступные игры
|
# Фильтруем доступные игры
|
||||||
available_games = []
|
available_games = []
|
||||||
for game in games_with_content:
|
for game in games_with_content:
|
||||||
|
# Исключаем изгнанные игры
|
||||||
|
if game.id in exiled_game_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
if game.game_type == GameType.PLAYTHROUGH.value:
|
if game.game_type == GameType.PLAYTHROUGH.value:
|
||||||
# Исключаем если игра уже завершена/дропнута
|
# Исключаем если игра уже завершена/дропнута
|
||||||
if game.id not in finished_playthrough_game_ids:
|
if game.id not in finished_playthrough_game_ids:
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from app.models import (
|
|||||||
Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
|
Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
|
||||||
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
|
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
|
||||||
Dispute, DisputeStatus, BonusAssignment, BonusAssignmentStatus, User,
|
Dispute, DisputeStatus, BonusAssignment, BonusAssignmentStatus, User,
|
||||||
|
ExiledGame,
|
||||||
)
|
)
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
MarathonCreate,
|
MarathonCreate,
|
||||||
@@ -35,6 +36,8 @@ from app.schemas import (
|
|||||||
MessageResponse,
|
MessageResponse,
|
||||||
UserPublic,
|
UserPublic,
|
||||||
SetParticipantRole,
|
SetParticipantRole,
|
||||||
|
OrganizerSkipRequest,
|
||||||
|
ExiledGameResponse,
|
||||||
)
|
)
|
||||||
from app.services.telegram_notifier import telegram_notifier
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
|
||||||
@@ -1004,3 +1007,224 @@ async def resolve_marathon_dispute(
|
|||||||
return MessageResponse(
|
return MessageResponse(
|
||||||
message=f"Dispute resolved as {'valid' if data.is_valid else 'invalid'}"
|
message=f"Dispute resolved as {'valid' if data.is_valid else 'invalid'}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============= Moderation Endpoints =============
|
||||||
|
|
||||||
|
@router.post("/{marathon_id}/participants/{user_id}/skip-assignment", response_model=MessageResponse)
|
||||||
|
async def organizer_skip_assignment(
|
||||||
|
marathon_id: int,
|
||||||
|
user_id: int,
|
||||||
|
data: OrganizerSkipRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Organizer skips a participant's current assignment.
|
||||||
|
|
||||||
|
- No penalty for participant
|
||||||
|
- Streak is preserved
|
||||||
|
- Optionally exile the game from participant's pool
|
||||||
|
"""
|
||||||
|
await require_organizer(db, current_user, marathon_id)
|
||||||
|
|
||||||
|
# Get marathon
|
||||||
|
result = await db.execute(
|
||||||
|
select(Marathon).where(Marathon.id == marathon_id)
|
||||||
|
)
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
if not marathon:
|
||||||
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||||
|
|
||||||
|
# Get target participant
|
||||||
|
result = await db.execute(
|
||||||
|
select(Participant)
|
||||||
|
.options(selectinload(Participant.user))
|
||||||
|
.where(
|
||||||
|
Participant.marathon_id == marathon_id,
|
||||||
|
Participant.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
participant = result.scalar_one_or_none()
|
||||||
|
if not participant:
|
||||||
|
raise HTTPException(status_code=404, detail="Participant not found")
|
||||||
|
|
||||||
|
# Get active assignment (exclude event assignments)
|
||||||
|
result = await db.execute(
|
||||||
|
select(Assignment)
|
||||||
|
.options(
|
||||||
|
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||||
|
selectinload(Assignment.game),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Assignment.participant_id == participant.id,
|
||||||
|
Assignment.status == AssignmentStatus.ACTIVE.value,
|
||||||
|
Assignment.is_event_assignment == False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assignment = result.scalar_one_or_none()
|
||||||
|
if not assignment:
|
||||||
|
raise HTTPException(status_code=400, detail="Participant has no active assignment")
|
||||||
|
|
||||||
|
# Get game info
|
||||||
|
if assignment.is_playthrough:
|
||||||
|
game = assignment.game
|
||||||
|
game_id = game.id
|
||||||
|
game_title = game.title
|
||||||
|
else:
|
||||||
|
game = assignment.challenge.game
|
||||||
|
game_id = game.id
|
||||||
|
game_title = game.title
|
||||||
|
|
||||||
|
# Skip the assignment (no penalty)
|
||||||
|
from datetime import datetime
|
||||||
|
assignment.status = AssignmentStatus.DROPPED.value
|
||||||
|
assignment.completed_at = datetime.utcnow()
|
||||||
|
# Note: We do NOT reset streak or increment drop_count
|
||||||
|
|
||||||
|
# Exile the game if requested
|
||||||
|
if data.exile:
|
||||||
|
# Check if already exiled
|
||||||
|
existing = await db.execute(
|
||||||
|
select(ExiledGame).where(
|
||||||
|
ExiledGame.participant_id == participant.id,
|
||||||
|
ExiledGame.game_id == game_id,
|
||||||
|
ExiledGame.is_active == True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not existing.scalar_one_or_none():
|
||||||
|
exiled = ExiledGame(
|
||||||
|
participant_id=participant.id,
|
||||||
|
game_id=game_id,
|
||||||
|
assignment_id=assignment.id,
|
||||||
|
exiled_by="organizer",
|
||||||
|
reason=data.reason,
|
||||||
|
)
|
||||||
|
db.add(exiled)
|
||||||
|
|
||||||
|
# Log activity
|
||||||
|
activity = Activity(
|
||||||
|
marathon_id=marathon_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
type=ActivityType.MODERATION.value,
|
||||||
|
data={
|
||||||
|
"action": "skip_assignment",
|
||||||
|
"target_user_id": user_id,
|
||||||
|
"target_nickname": participant.user.nickname,
|
||||||
|
"assignment_id": assignment.id,
|
||||||
|
"game_id": game_id,
|
||||||
|
"game_title": game_title,
|
||||||
|
"exile": data.exile,
|
||||||
|
"reason": data.reason,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.add(activity)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Send notification
|
||||||
|
await telegram_notifier.notify_assignment_skipped_by_moderator(
|
||||||
|
db,
|
||||||
|
user=participant.user,
|
||||||
|
marathon_title=marathon.title,
|
||||||
|
game_title=game_title,
|
||||||
|
exiled=data.exile,
|
||||||
|
reason=data.reason,
|
||||||
|
moderator_nickname=current_user.nickname,
|
||||||
|
)
|
||||||
|
|
||||||
|
exile_msg = " and exiled from pool" if data.exile else ""
|
||||||
|
return MessageResponse(message=f"Assignment skipped{exile_msg}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{marathon_id}/participants/{user_id}/exiled-games", response_model=list[ExiledGameResponse])
|
||||||
|
async def get_participant_exiled_games(
|
||||||
|
marathon_id: int,
|
||||||
|
user_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Get list of exiled games for a participant (organizers only)"""
|
||||||
|
await require_organizer(db, current_user, marathon_id)
|
||||||
|
|
||||||
|
# Get participant
|
||||||
|
result = await db.execute(
|
||||||
|
select(Participant).where(
|
||||||
|
Participant.marathon_id == marathon_id,
|
||||||
|
Participant.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
participant = result.scalar_one_or_none()
|
||||||
|
if not participant:
|
||||||
|
raise HTTPException(status_code=404, detail="Participant not found")
|
||||||
|
|
||||||
|
# Get exiled games
|
||||||
|
result = await db.execute(
|
||||||
|
select(ExiledGame)
|
||||||
|
.options(selectinload(ExiledGame.game))
|
||||||
|
.where(
|
||||||
|
ExiledGame.participant_id == participant.id,
|
||||||
|
ExiledGame.is_active == True,
|
||||||
|
)
|
||||||
|
.order_by(ExiledGame.exiled_at.desc())
|
||||||
|
)
|
||||||
|
exiled_games = result.scalars().all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
ExiledGameResponse(
|
||||||
|
id=eg.id,
|
||||||
|
game_id=eg.game_id,
|
||||||
|
game_title=eg.game.title,
|
||||||
|
exiled_at=eg.exiled_at,
|
||||||
|
exiled_by=eg.exiled_by,
|
||||||
|
reason=eg.reason,
|
||||||
|
)
|
||||||
|
for eg in exiled_games
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{marathon_id}/participants/{user_id}/exiled-games/{game_id}/restore", response_model=MessageResponse)
|
||||||
|
async def restore_exiled_game(
|
||||||
|
marathon_id: int,
|
||||||
|
user_id: int,
|
||||||
|
game_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Restore an exiled game back to participant's pool (organizers only)"""
|
||||||
|
await require_organizer(db, current_user, marathon_id)
|
||||||
|
|
||||||
|
# Get participant
|
||||||
|
result = await db.execute(
|
||||||
|
select(Participant).where(
|
||||||
|
Participant.marathon_id == marathon_id,
|
||||||
|
Participant.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
participant = result.scalar_one_or_none()
|
||||||
|
if not participant:
|
||||||
|
raise HTTPException(status_code=404, detail="Participant not found")
|
||||||
|
|
||||||
|
# Get exiled game
|
||||||
|
result = await db.execute(
|
||||||
|
select(ExiledGame)
|
||||||
|
.options(selectinload(ExiledGame.game))
|
||||||
|
.where(
|
||||||
|
ExiledGame.participant_id == participant.id,
|
||||||
|
ExiledGame.game_id == game_id,
|
||||||
|
ExiledGame.is_active == True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
exiled_game = result.scalar_one_or_none()
|
||||||
|
if not exiled_game:
|
||||||
|
raise HTTPException(status_code=404, detail="Exiled game not found")
|
||||||
|
|
||||||
|
# Restore (soft-delete)
|
||||||
|
from datetime import datetime
|
||||||
|
exiled_game.is_active = False
|
||||||
|
exiled_game.unexiled_at = datetime.utcnow()
|
||||||
|
exiled_game.unexiled_by = "organizer"
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return MessageResponse(message=f"Game '{exiled_game.game.title}' restored to pool")
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ from app.schemas import (
|
|||||||
CoinTransactionResponse, CoinsBalanceResponse, AdminCoinsRequest,
|
CoinTransactionResponse, CoinsBalanceResponse, AdminCoinsRequest,
|
||||||
CertificationRequestSchema, CertificationReviewRequest, CertificationStatusResponse,
|
CertificationRequestSchema, CertificationReviewRequest, CertificationStatusResponse,
|
||||||
ConsumablesStatusResponse, MessageResponse, SwapCandidate,
|
ConsumablesStatusResponse, MessageResponse, SwapCandidate,
|
||||||
|
AdminGrantItemRequest,
|
||||||
)
|
)
|
||||||
from app.schemas.user import UserPublic
|
from app.schemas.user import UserPublic
|
||||||
from app.services.shop import shop_service
|
from app.services.shop import shop_service
|
||||||
from app.services.coins import coins_service
|
from app.services.coins import coins_service
|
||||||
from app.services.consumables import consumables_service
|
from app.services.consumables import consumables_service
|
||||||
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
|
||||||
router = APIRouter(prefix="/shop", tags=["shop"])
|
router = APIRouter(prefix="/shop", tags=["shop"])
|
||||||
|
|
||||||
@@ -184,12 +186,12 @@ async def use_consumable(
|
|||||||
|
|
||||||
# For some consumables, we need the assignment
|
# For some consumables, we need the assignment
|
||||||
assignment = None
|
assignment = None
|
||||||
if data.item_code in ["skip", "wild_card", "copycat"]:
|
if data.item_code in ["skip", "skip_exile", "wild_card", "copycat"]:
|
||||||
if not data.assignment_id:
|
if not data.assignment_id:
|
||||||
raise HTTPException(status_code=400, detail=f"assignment_id is required for {data.item_code}")
|
raise HTTPException(status_code=400, detail=f"assignment_id is required for {data.item_code}")
|
||||||
|
|
||||||
# For copycat, we need bonus_assignments to properly handle playthrough
|
# For copycat and wild_card, we need bonus_assignments to properly handle playthrough
|
||||||
if data.item_code == "copycat":
|
if data.item_code in ("copycat", "wild_card"):
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Assignment)
|
select(Assignment)
|
||||||
.options(selectinload(Assignment.bonus_assignments))
|
.options(selectinload(Assignment.bonus_assignments))
|
||||||
@@ -213,6 +215,9 @@ async def use_consumable(
|
|||||||
if data.item_code == "skip":
|
if data.item_code == "skip":
|
||||||
effect = await consumables_service.use_skip(db, current_user, participant, marathon, assignment)
|
effect = await consumables_service.use_skip(db, current_user, participant, marathon, assignment)
|
||||||
effect_description = "Assignment skipped without penalty"
|
effect_description = "Assignment skipped without penalty"
|
||||||
|
elif data.item_code == "skip_exile":
|
||||||
|
effect = await consumables_service.use_skip_exile(db, current_user, participant, marathon, assignment)
|
||||||
|
effect_description = "Assignment skipped, game exiled from pool"
|
||||||
elif data.item_code == "boost":
|
elif data.item_code == "boost":
|
||||||
effect = await consumables_service.use_boost(db, current_user, participant, marathon)
|
effect = await consumables_service.use_boost(db, current_user, participant, marathon)
|
||||||
effect_description = f"Boost x{effect['multiplier']} activated for current assignment"
|
effect_description = f"Boost x{effect['multiplier']} activated for current assignment"
|
||||||
@@ -269,6 +274,7 @@ async def get_consumables_status(
|
|||||||
|
|
||||||
# Get inventory counts for all consumables
|
# Get inventory counts for all consumables
|
||||||
skips_available = await consumables_service.get_consumable_count(db, current_user.id, "skip")
|
skips_available = await consumables_service.get_consumable_count(db, current_user.id, "skip")
|
||||||
|
skip_exiles_available = await consumables_service.get_consumable_count(db, current_user.id, "skip_exile")
|
||||||
boosts_available = await consumables_service.get_consumable_count(db, current_user.id, "boost")
|
boosts_available = await consumables_service.get_consumable_count(db, current_user.id, "boost")
|
||||||
wild_cards_available = await consumables_service.get_consumable_count(db, current_user.id, "wild_card")
|
wild_cards_available = await consumables_service.get_consumable_count(db, current_user.id, "wild_card")
|
||||||
lucky_dice_available = await consumables_service.get_consumable_count(db, current_user.id, "lucky_dice")
|
lucky_dice_available = await consumables_service.get_consumable_count(db, current_user.id, "lucky_dice")
|
||||||
@@ -282,6 +288,7 @@ async def get_consumables_status(
|
|||||||
|
|
||||||
return ConsumablesStatusResponse(
|
return ConsumablesStatusResponse(
|
||||||
skips_available=skips_available,
|
skips_available=skips_available,
|
||||||
|
skip_exiles_available=skip_exiles_available,
|
||||||
skips_used=participant.skips_used,
|
skips_used=participant.skips_used,
|
||||||
skips_remaining=skips_remaining,
|
skips_remaining=skips_remaining,
|
||||||
boosts_available=boosts_available,
|
boosts_available=boosts_available,
|
||||||
@@ -749,3 +756,149 @@ async def admin_review_certification(
|
|||||||
certified_by_nickname=current_user.nickname if data.approve else None,
|
certified_by_nickname=current_user.nickname if data.approve else None,
|
||||||
rejection_reason=marathon.certification_rejection_reason,
|
rejection_reason=marathon.certification_rejection_reason,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# === Admin Item Granting ===
|
||||||
|
|
||||||
|
@router.post("/admin/users/{user_id}/items/grant", response_model=MessageResponse)
|
||||||
|
async def admin_grant_item(
|
||||||
|
user_id: int,
|
||||||
|
data: AdminGrantItemRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Grant an item to a user (admin only)"""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
# Get target user
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
# Get item
|
||||||
|
item = await shop_service.get_item_by_id(db, data.item_id)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
|
||||||
|
# Check if user already has this item in inventory
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserInventory).where(
|
||||||
|
UserInventory.user_id == user_id,
|
||||||
|
UserInventory.item_id == data.item_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Add to quantity
|
||||||
|
existing.quantity += data.quantity
|
||||||
|
else:
|
||||||
|
# Create new inventory item
|
||||||
|
inventory_item = UserInventory(
|
||||||
|
user_id=user_id,
|
||||||
|
item_id=data.item_id,
|
||||||
|
quantity=data.quantity,
|
||||||
|
)
|
||||||
|
db.add(inventory_item)
|
||||||
|
|
||||||
|
# Log the action (using coin transaction as audit log)
|
||||||
|
transaction = CoinTransaction(
|
||||||
|
user_id=user_id,
|
||||||
|
amount=0,
|
||||||
|
transaction_type="admin_grant_item",
|
||||||
|
description=f"Admin granted {item.name} x{data.quantity}: {data.reason}",
|
||||||
|
reference_type="admin_action",
|
||||||
|
reference_id=current_user.id,
|
||||||
|
)
|
||||||
|
db.add(transaction)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Send Telegram notification
|
||||||
|
await telegram_notifier.notify_item_granted(
|
||||||
|
user=user,
|
||||||
|
item_name=item.name,
|
||||||
|
quantity=data.quantity,
|
||||||
|
reason=data.reason,
|
||||||
|
admin_nickname=current_user.nickname,
|
||||||
|
)
|
||||||
|
|
||||||
|
return MessageResponse(message=f"Granted {item.name} x{data.quantity} to {user.nickname}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/users/{user_id}/inventory", response_model=list[InventoryItemResponse])
|
||||||
|
async def admin_get_user_inventory(
|
||||||
|
user_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
item_type: str | None = None,
|
||||||
|
):
|
||||||
|
"""Get a user's inventory (admin only)"""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
# Check user exists
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
inventory = await shop_service.get_user_inventory(db, user_id, item_type)
|
||||||
|
return [InventoryItemResponse.model_validate(inv) for inv in inventory]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/admin/users/{user_id}/inventory/{inventory_id}", response_model=MessageResponse)
|
||||||
|
async def admin_remove_inventory_item(
|
||||||
|
user_id: int,
|
||||||
|
inventory_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
quantity: int = 1,
|
||||||
|
):
|
||||||
|
"""Remove an item from user's inventory (admin only)"""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
# Check user exists
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
# Get inventory item
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserInventory)
|
||||||
|
.options(selectinload(UserInventory.item))
|
||||||
|
.where(
|
||||||
|
UserInventory.id == inventory_id,
|
||||||
|
UserInventory.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
inv = result.scalar_one_or_none()
|
||||||
|
if not inv:
|
||||||
|
raise HTTPException(status_code=404, detail="Inventory item not found")
|
||||||
|
|
||||||
|
item_name = inv.item.name
|
||||||
|
|
||||||
|
if quantity >= inv.quantity:
|
||||||
|
# Remove entirely
|
||||||
|
await db.delete(inv)
|
||||||
|
removed_qty = inv.quantity
|
||||||
|
else:
|
||||||
|
# Reduce quantity
|
||||||
|
inv.quantity -= quantity
|
||||||
|
removed_qty = quantity
|
||||||
|
|
||||||
|
# Log the action
|
||||||
|
transaction = CoinTransaction(
|
||||||
|
user_id=user_id,
|
||||||
|
amount=0,
|
||||||
|
transaction_type="admin_remove_item",
|
||||||
|
description=f"Admin removed {item_name} x{removed_qty}",
|
||||||
|
reference_type="admin_action",
|
||||||
|
reference_id=current_user.id,
|
||||||
|
)
|
||||||
|
db.add(transaction)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return MessageResponse(message=f"Removed {item_name} x{removed_qty} from {user.nickname}")
|
||||||
|
|||||||
@@ -442,6 +442,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
|
|||||||
drop_penalty=drop_penalty,
|
drop_penalty=drop_penalty,
|
||||||
bonus_challenges=bonus_responses,
|
bonus_challenges=bonus_responses,
|
||||||
event_type=assignment.event_type,
|
event_type=assignment.event_type,
|
||||||
|
tracked_time_minutes=assignment.tracked_time_minutes,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Regular challenge assignment
|
# Regular challenge assignment
|
||||||
@@ -477,6 +478,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
|
|||||||
completed_at=assignment.completed_at,
|
completed_at=assignment.completed_at,
|
||||||
drop_penalty=drop_penalty,
|
drop_penalty=drop_penalty,
|
||||||
event_type=assignment.event_type,
|
event_type=assignment.event_type,
|
||||||
|
tracked_time_minutes=assignment.tracked_time_minutes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1115,6 +1117,7 @@ async def get_my_history(
|
|||||||
started_at=a.started_at,
|
started_at=a.started_at,
|
||||||
completed_at=a.completed_at,
|
completed_at=a.completed_at,
|
||||||
bonus_challenges=bonus_responses,
|
bonus_challenges=bonus_responses,
|
||||||
|
tracked_time_minutes=a.tracked_time_minutes,
|
||||||
))
|
))
|
||||||
else:
|
else:
|
||||||
# Regular challenge assignment
|
# Regular challenge assignment
|
||||||
@@ -1147,6 +1150,7 @@ async def get_my_history(
|
|||||||
streak_at_completion=a.streak_at_completion,
|
streak_at_completion=a.streak_at_completion,
|
||||||
started_at=a.started_at,
|
started_at=a.started_at,
|
||||||
completed_at=a.completed_at,
|
completed_at=a.completed_at,
|
||||||
|
tracked_time_minutes=a.tracked_time_minutes,
|
||||||
))
|
))
|
||||||
|
|
||||||
return responses
|
return responses
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from app.models.coin_transaction import CoinTransaction, CoinTransactionType
|
|||||||
from app.models.consumable_usage import ConsumableUsage
|
from app.models.consumable_usage import ConsumableUsage
|
||||||
from app.models.promo_code import PromoCode, PromoCodeRedemption
|
from app.models.promo_code import PromoCode, PromoCodeRedemption
|
||||||
from app.models.widget_token import WidgetToken
|
from app.models.widget_token import WidgetToken
|
||||||
|
from app.models.exiled_game import ExiledGame
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@@ -67,4 +68,5 @@ __all__ = [
|
|||||||
"PromoCode",
|
"PromoCode",
|
||||||
"PromoCodeRedemption",
|
"PromoCodeRedemption",
|
||||||
"WidgetToken",
|
"WidgetToken",
|
||||||
|
"ExiledGame",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class ActivityType(str, Enum):
|
|||||||
EVENT_END = "event_end"
|
EVENT_END = "event_end"
|
||||||
SWAP = "swap"
|
SWAP = "swap"
|
||||||
GAME_CHOICE = "game_choice"
|
GAME_CHOICE = "game_choice"
|
||||||
|
MODERATION = "moderation"
|
||||||
|
|
||||||
|
|
||||||
class Activity(Base):
|
class Activity(Base):
|
||||||
|
|||||||
37
backend/app/models/exiled_game.py
Normal file
37
backend/app/models/exiled_game.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, Boolean, Integer, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ExiledGame(Base):
|
||||||
|
"""Изгнанные игры участника - не будут выпадать при спине"""
|
||||||
|
__tablename__ = "exiled_games"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("participant_id", "game_id", name="unique_participant_game_exile"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
participant_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("participants.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
game_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("games.id", ondelete="CASCADE")
|
||||||
|
)
|
||||||
|
assignment_id: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, ForeignKey("assignments.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
exiled_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
exiled_by: Mapped[str] = mapped_column(String(20)) # "user" | "organizer" | "admin"
|
||||||
|
reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
|
||||||
|
# Soft-delete для истории
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||||
|
unexiled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
unexiled_by: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
participant: Mapped["Participant"] = relationship("Participant")
|
||||||
|
game: Mapped["Game"] = relationship("Game")
|
||||||
|
assignment: Mapped["Assignment"] = relationship("Assignment")
|
||||||
@@ -28,6 +28,7 @@ class ItemRarity(str, Enum):
|
|||||||
|
|
||||||
class ConsumableType(str, Enum):
|
class ConsumableType(str, Enum):
|
||||||
SKIP = "skip"
|
SKIP = "skip"
|
||||||
|
SKIP_EXILE = "skip_exile" # Скип с изгнанием игры из пула
|
||||||
BOOST = "boost"
|
BOOST = "boost"
|
||||||
WILD_CARD = "wild_card"
|
WILD_CARD = "wild_card"
|
||||||
LUCKY_DICE = "lucky_dice"
|
LUCKY_DICE = "lucky_dice"
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ from app.schemas.marathon import (
|
|||||||
JoinMarathon,
|
JoinMarathon,
|
||||||
LeaderboardEntry,
|
LeaderboardEntry,
|
||||||
SetParticipantRole,
|
SetParticipantRole,
|
||||||
|
OrganizerSkipRequest,
|
||||||
|
ExiledGameResponse,
|
||||||
)
|
)
|
||||||
from app.schemas.game import (
|
from app.schemas.game import (
|
||||||
GameCreate,
|
GameCreate,
|
||||||
@@ -124,6 +126,7 @@ from app.schemas.shop import (
|
|||||||
CertificationReviewRequest,
|
CertificationReviewRequest,
|
||||||
CertificationStatusResponse,
|
CertificationStatusResponse,
|
||||||
ConsumablesStatusResponse,
|
ConsumablesStatusResponse,
|
||||||
|
AdminGrantItemRequest,
|
||||||
)
|
)
|
||||||
from app.schemas.promo_code import (
|
from app.schemas.promo_code import (
|
||||||
PromoCodeCreate,
|
PromoCodeCreate,
|
||||||
@@ -170,6 +173,8 @@ __all__ = [
|
|||||||
"JoinMarathon",
|
"JoinMarathon",
|
||||||
"LeaderboardEntry",
|
"LeaderboardEntry",
|
||||||
"SetParticipantRole",
|
"SetParticipantRole",
|
||||||
|
"OrganizerSkipRequest",
|
||||||
|
"ExiledGameResponse",
|
||||||
# Game
|
# Game
|
||||||
"GameCreate",
|
"GameCreate",
|
||||||
"GameUpdate",
|
"GameUpdate",
|
||||||
@@ -262,6 +267,7 @@ __all__ = [
|
|||||||
"CertificationReviewRequest",
|
"CertificationReviewRequest",
|
||||||
"CertificationStatusResponse",
|
"CertificationStatusResponse",
|
||||||
"ConsumablesStatusResponse",
|
"ConsumablesStatusResponse",
|
||||||
|
"AdminGrantItemRequest",
|
||||||
# Promo
|
# Promo
|
||||||
"PromoCodeCreate",
|
"PromoCodeCreate",
|
||||||
"PromoCodeUpdate",
|
"PromoCodeUpdate",
|
||||||
|
|||||||
@@ -128,10 +128,16 @@ class SwapCandidate(BaseModel):
|
|||||||
"""Participant available for assignment swap"""
|
"""Participant available for assignment swap"""
|
||||||
participant_id: int
|
participant_id: int
|
||||||
user: UserPublic
|
user: UserPublic
|
||||||
challenge_title: str
|
is_playthrough: bool = False
|
||||||
challenge_description: str
|
# Challenge fields (used when is_playthrough=False)
|
||||||
challenge_points: int
|
challenge_title: str | None = None
|
||||||
challenge_difficulty: str
|
challenge_description: str | None = None
|
||||||
|
challenge_points: int | None = None
|
||||||
|
challenge_difficulty: str | None = None
|
||||||
|
# Playthrough fields (used when is_playthrough=True)
|
||||||
|
playthrough_description: str | None = None
|
||||||
|
playthrough_points: int | None = None
|
||||||
|
# Common field
|
||||||
game_title: str
|
game_title: str
|
||||||
|
|
||||||
|
|
||||||
@@ -145,11 +151,17 @@ class SwapRequestCreate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class SwapRequestChallengeInfo(BaseModel):
|
class SwapRequestChallengeInfo(BaseModel):
|
||||||
"""Challenge info for swap request display"""
|
"""Challenge or playthrough info for swap request display"""
|
||||||
title: str
|
is_playthrough: bool = False
|
||||||
description: str
|
# Challenge fields (used when is_playthrough=False)
|
||||||
points: int
|
title: str | None = None
|
||||||
difficulty: str
|
description: str | None = None
|
||||||
|
points: int | None = None
|
||||||
|
difficulty: str | None = None
|
||||||
|
# Playthrough fields (used when is_playthrough=True)
|
||||||
|
playthrough_description: str | None = None
|
||||||
|
playthrough_points: int | None = None
|
||||||
|
# Common field
|
||||||
game_title: str
|
game_title: str
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ class GameResponse(GameBase):
|
|||||||
|
|
||||||
class PlaythroughInfo(BaseModel):
|
class PlaythroughInfo(BaseModel):
|
||||||
"""Информация о прохождении для игр типа playthrough"""
|
"""Информация о прохождении для игр типа playthrough"""
|
||||||
description: str
|
description: str | None = None
|
||||||
points: int
|
points: int | None = None
|
||||||
proof_type: str
|
proof_type: str | None = None
|
||||||
proof_hint: str | None = None
|
proof_hint: str | None = None
|
||||||
|
|||||||
@@ -128,3 +128,23 @@ class LeaderboardEntry(BaseModel):
|
|||||||
current_streak: int
|
current_streak: int
|
||||||
completed_count: int
|
completed_count: int
|
||||||
dropped_count: int
|
dropped_count: int
|
||||||
|
|
||||||
|
|
||||||
|
# Moderation schemas
|
||||||
|
class OrganizerSkipRequest(BaseModel):
|
||||||
|
"""Request to skip a participant's assignment by organizer"""
|
||||||
|
exile: bool = False # If true, also exile the game from participant's pool
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExiledGameResponse(BaseModel):
|
||||||
|
"""Exiled game info"""
|
||||||
|
id: int
|
||||||
|
game_id: int
|
||||||
|
game_title: str
|
||||||
|
exiled_at: datetime
|
||||||
|
exiled_by: str # "user" | "organizer" | "admin"
|
||||||
|
reason: str | None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ class CertificationStatusResponse(BaseModel):
|
|||||||
class ConsumablesStatusResponse(BaseModel):
|
class ConsumablesStatusResponse(BaseModel):
|
||||||
"""Schema for participant's consumables status in a marathon"""
|
"""Schema for participant's consumables status in a marathon"""
|
||||||
skips_available: int # From inventory
|
skips_available: int # From inventory
|
||||||
|
skip_exiles_available: int = 0 # From inventory (skip with exile)
|
||||||
skips_used: int # In this marathon
|
skips_used: int # In this marathon
|
||||||
skips_remaining: int | None # Based on marathon limit
|
skips_remaining: int | None # Based on marathon limit
|
||||||
boosts_available: int # From inventory
|
boosts_available: int # From inventory
|
||||||
@@ -204,3 +205,12 @@ class ConsumablesStatusResponse(BaseModel):
|
|||||||
copycats_available: int # From inventory
|
copycats_available: int # From inventory
|
||||||
undos_available: int # From inventory
|
undos_available: int # From inventory
|
||||||
can_undo: bool # Has drop data to undo
|
can_undo: bool # Has drop data to undo
|
||||||
|
|
||||||
|
|
||||||
|
# === Admin Item Granting ===
|
||||||
|
|
||||||
|
class AdminGrantItemRequest(BaseModel):
|
||||||
|
"""Schema for admin granting item to user"""
|
||||||
|
item_id: int
|
||||||
|
quantity: int = Field(default=1, ge=1, le=100)
|
||||||
|
reason: str = Field(..., min_length=1, max_length=500)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ Consumables Service - handles consumable items usage
|
|||||||
|
|
||||||
Consumables:
|
Consumables:
|
||||||
- skip: Skip current assignment without penalty
|
- skip: Skip current assignment without penalty
|
||||||
|
- skip_exile: Skip + permanently exile game from pool
|
||||||
- boost: x1.5 multiplier for current assignment
|
- boost: x1.5 multiplier for current assignment
|
||||||
- wild_card: Choose a game, get random challenge from it
|
- wild_card: Choose a game, get random challenge from it
|
||||||
- lucky_dice: Random multiplier (0.5, 1.0, 1.5, 2.0, 2.5, 3.0)
|
- lucky_dice: Random multiplier (0.5, 1.0, 1.5, 2.0, 2.5, 3.0)
|
||||||
@@ -19,7 +20,7 @@ from sqlalchemy.orm import selectinload
|
|||||||
from app.models import (
|
from app.models import (
|
||||||
User, Participant, Marathon, Assignment, AssignmentStatus,
|
User, Participant, Marathon, Assignment, AssignmentStatus,
|
||||||
ShopItem, UserInventory, ConsumableUsage, ConsumableType, Game, Challenge,
|
ShopItem, UserInventory, ConsumableUsage, ConsumableType, Game, Challenge,
|
||||||
BonusAssignment
|
BonusAssignment, ExiledGame, GameType
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -98,6 +99,106 @@ class ConsumablesService:
|
|||||||
"streak_preserved": True,
|
"streak_preserved": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def use_skip_exile(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
participant: Participant,
|
||||||
|
marathon: Marathon,
|
||||||
|
assignment: Assignment,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Use Skip with Exile - skip assignment AND permanently exile game from pool.
|
||||||
|
|
||||||
|
- No streak loss
|
||||||
|
- No drop penalty
|
||||||
|
- Game is permanently excluded from participant's pool
|
||||||
|
|
||||||
|
Returns: dict with result info
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If skips not allowed or limit reached
|
||||||
|
"""
|
||||||
|
# Check marathon settings (same as regular skip)
|
||||||
|
if not marathon.allow_skips:
|
||||||
|
raise HTTPException(status_code=400, detail="Skips are not allowed in this marathon")
|
||||||
|
|
||||||
|
if marathon.max_skips_per_participant is not None:
|
||||||
|
if participant.skips_used >= marathon.max_skips_per_participant:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Skip limit reached ({marathon.max_skips_per_participant} per participant)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check assignment is active
|
||||||
|
if assignment.status != AssignmentStatus.ACTIVE.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Can only skip active assignments")
|
||||||
|
|
||||||
|
# Get game_id (different for playthrough vs challenges)
|
||||||
|
if assignment.is_playthrough:
|
||||||
|
game_id = assignment.game_id
|
||||||
|
else:
|
||||||
|
# Load challenge to get game_id
|
||||||
|
result = await db.execute(
|
||||||
|
select(Challenge).where(Challenge.id == assignment.challenge_id)
|
||||||
|
)
|
||||||
|
challenge = result.scalar_one()
|
||||||
|
game_id = challenge.game_id
|
||||||
|
|
||||||
|
# Check if game is already exiled
|
||||||
|
existing = await db.execute(
|
||||||
|
select(ExiledGame).where(
|
||||||
|
ExiledGame.participant_id == participant.id,
|
||||||
|
ExiledGame.game_id == game_id,
|
||||||
|
ExiledGame.is_active == True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=400, detail="Game is already exiled")
|
||||||
|
|
||||||
|
# Consume skip_exile from inventory
|
||||||
|
item = await self._consume_item(db, user, ConsumableType.SKIP_EXILE.value)
|
||||||
|
|
||||||
|
# Mark assignment as dropped (without penalty)
|
||||||
|
assignment.status = AssignmentStatus.DROPPED.value
|
||||||
|
assignment.completed_at = datetime.utcnow()
|
||||||
|
|
||||||
|
# Track skip usage
|
||||||
|
participant.skips_used += 1
|
||||||
|
|
||||||
|
# Add game to exiled list
|
||||||
|
exiled = ExiledGame(
|
||||||
|
participant_id=participant.id,
|
||||||
|
game_id=game_id,
|
||||||
|
assignment_id=assignment.id,
|
||||||
|
exiled_by="user",
|
||||||
|
)
|
||||||
|
db.add(exiled)
|
||||||
|
|
||||||
|
# Log usage
|
||||||
|
usage = ConsumableUsage(
|
||||||
|
user_id=user.id,
|
||||||
|
item_id=item.id,
|
||||||
|
marathon_id=marathon.id,
|
||||||
|
assignment_id=assignment.id,
|
||||||
|
effect_data={
|
||||||
|
"type": "skip_exile",
|
||||||
|
"skipped_without_penalty": True,
|
||||||
|
"game_exiled": True,
|
||||||
|
"game_id": game_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db.add(usage)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"skipped": True,
|
||||||
|
"exiled": True,
|
||||||
|
"game_id": game_id,
|
||||||
|
"penalty": 0,
|
||||||
|
"streak_preserved": True,
|
||||||
|
}
|
||||||
|
|
||||||
async def use_boost(
|
async def use_boost(
|
||||||
self,
|
self,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
@@ -157,11 +258,15 @@ class ConsumablesService:
|
|||||||
game_id: int,
|
game_id: int,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Use Wild Card - choose a game and get a random challenge from it.
|
Use Wild Card - choose a game and switch to it.
|
||||||
|
|
||||||
- Current assignment is replaced
|
For challenges game type:
|
||||||
- New challenge is randomly selected from the chosen game
|
- New challenge is randomly selected from the chosen game
|
||||||
- Game must be in the marathon
|
- Assignment becomes a regular challenge
|
||||||
|
|
||||||
|
For playthrough game type:
|
||||||
|
- Assignment becomes a playthrough of the chosen game
|
||||||
|
- Bonus assignments are created from game's challenges
|
||||||
|
|
||||||
Returns: dict with new assignment info
|
Returns: dict with new assignment info
|
||||||
|
|
||||||
@@ -174,9 +279,10 @@ class ConsumablesService:
|
|||||||
if assignment.status != AssignmentStatus.ACTIVE.value:
|
if assignment.status != AssignmentStatus.ACTIVE.value:
|
||||||
raise HTTPException(status_code=400, detail="Can only use wild card on active assignments")
|
raise HTTPException(status_code=400, detail="Can only use wild card on active assignments")
|
||||||
|
|
||||||
# Verify game is in this marathon
|
# Verify game is in this marathon and load challenges
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Game)
|
select(Game)
|
||||||
|
.options(selectinload(Game.challenges))
|
||||||
.where(
|
.where(
|
||||||
Game.id == game_id,
|
Game.id == game_id,
|
||||||
Game.marathon_id == marathon.id,
|
Game.marathon_id == marathon.id,
|
||||||
@@ -187,31 +293,52 @@ class ConsumablesService:
|
|||||||
if not game:
|
if not game:
|
||||||
raise HTTPException(status_code=400, detail="Game not found in this marathon")
|
raise HTTPException(status_code=400, detail="Game not found in this marathon")
|
||||||
|
|
||||||
# Get random challenge from this game
|
# Store old assignment info for logging
|
||||||
result = await db.execute(
|
old_game_id = assignment.game_id
|
||||||
select(Challenge)
|
old_challenge_id = assignment.challenge_id
|
||||||
.where(Challenge.game_id == game_id)
|
old_is_playthrough = assignment.is_playthrough
|
||||||
.order_by(func.random())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
new_challenge = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not new_challenge:
|
|
||||||
raise HTTPException(status_code=400, detail="No challenges available for this game")
|
|
||||||
|
|
||||||
# Consume wild card from inventory
|
# Consume wild card from inventory
|
||||||
item = await self._consume_item(db, user, ConsumableType.WILD_CARD.value)
|
item = await self._consume_item(db, user, ConsumableType.WILD_CARD.value)
|
||||||
|
|
||||||
# Store old assignment info for logging
|
# Delete existing bonus assignments if any
|
||||||
old_game_id = assignment.game_id
|
if assignment.bonus_assignments:
|
||||||
old_challenge_id = assignment.challenge_id
|
for ba in assignment.bonus_assignments:
|
||||||
|
await db.delete(ba)
|
||||||
|
|
||||||
# Update assignment with new challenge
|
new_challenge_id = None
|
||||||
assignment.game_id = game_id
|
new_challenge_title = None
|
||||||
assignment.challenge_id = new_challenge.id
|
|
||||||
# Reset timestamps since it's a new challenge
|
if game.game_type == GameType.PLAYTHROUGH.value:
|
||||||
|
# Switch to playthrough mode
|
||||||
|
assignment.game_id = game_id
|
||||||
|
assignment.challenge_id = None
|
||||||
|
assignment.is_playthrough = True
|
||||||
|
|
||||||
|
# Create bonus assignments from game's challenges
|
||||||
|
for ch in game.challenges:
|
||||||
|
bonus = BonusAssignment(
|
||||||
|
main_assignment_id=assignment.id,
|
||||||
|
challenge_id=ch.id,
|
||||||
|
)
|
||||||
|
db.add(bonus)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Switch to challenge mode - get random challenge
|
||||||
|
if not game.challenges:
|
||||||
|
raise HTTPException(status_code=400, detail="No challenges available for this game")
|
||||||
|
|
||||||
|
new_challenge = random.choice(game.challenges)
|
||||||
|
new_challenge_id = new_challenge.id
|
||||||
|
new_challenge_title = new_challenge.title
|
||||||
|
|
||||||
|
assignment.game_id = game_id
|
||||||
|
assignment.challenge_id = new_challenge_id
|
||||||
|
assignment.is_playthrough = False
|
||||||
|
|
||||||
|
# Reset timestamps since it's a new assignment
|
||||||
assignment.started_at = datetime.utcnow()
|
assignment.started_at = datetime.utcnow()
|
||||||
assignment.deadline = None # Will be recalculated if needed
|
assignment.deadline = None
|
||||||
|
|
||||||
# Log usage
|
# Log usage
|
||||||
usage = ConsumableUsage(
|
usage = ConsumableUsage(
|
||||||
@@ -223,8 +350,10 @@ class ConsumablesService:
|
|||||||
"type": "wild_card",
|
"type": "wild_card",
|
||||||
"old_game_id": old_game_id,
|
"old_game_id": old_game_id,
|
||||||
"old_challenge_id": old_challenge_id,
|
"old_challenge_id": old_challenge_id,
|
||||||
|
"old_is_playthrough": old_is_playthrough,
|
||||||
"new_game_id": game_id,
|
"new_game_id": game_id,
|
||||||
"new_challenge_id": new_challenge.id,
|
"new_challenge_id": new_challenge_id,
|
||||||
|
"new_is_playthrough": game.game_type == GameType.PLAYTHROUGH.value,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
db.add(usage)
|
db.add(usage)
|
||||||
@@ -232,9 +361,11 @@ class ConsumablesService:
|
|||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"game_id": game_id,
|
"game_id": game_id,
|
||||||
"game_name": game.name,
|
"game_name": game.title,
|
||||||
"challenge_id": new_challenge.id,
|
"game_type": game.game_type,
|
||||||
"challenge_title": new_challenge.title,
|
"is_playthrough": game.game_type == GameType.PLAYTHROUGH.value,
|
||||||
|
"challenge_id": new_challenge_id,
|
||||||
|
"challenge_title": new_challenge_title,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def use_lucky_dice(
|
async def use_lucky_dice(
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ class PointsService:
|
|||||||
def calculate_drop_penalty(
|
def calculate_drop_penalty(
|
||||||
self,
|
self,
|
||||||
consecutive_drops: int,
|
consecutive_drops: int,
|
||||||
challenge_points: int,
|
challenge_points: int | None,
|
||||||
event: Event | None = None
|
event: Event | None = None
|
||||||
) -> int:
|
) -> int:
|
||||||
"""
|
"""
|
||||||
@@ -80,6 +80,10 @@ class PointsService:
|
|||||||
Returns:
|
Returns:
|
||||||
Penalty points to subtract
|
Penalty points to subtract
|
||||||
"""
|
"""
|
||||||
|
# No penalty if no points defined
|
||||||
|
if challenge_points is None:
|
||||||
|
return 0
|
||||||
|
|
||||||
# Double risk event = free drops
|
# Double risk event = free drops
|
||||||
if event and event.type == EventType.DOUBLE_RISK.value:
|
if event and event.type == EventType.DOUBLE_RISK.value:
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -608,6 +608,57 @@ class TelegramNotifier:
|
|||||||
reply_markup=reply_markup
|
reply_markup=reply_markup
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def notify_assignment_skipped_by_moderator(
|
||||||
|
self,
|
||||||
|
db,
|
||||||
|
user,
|
||||||
|
marathon_title: str,
|
||||||
|
game_title: str,
|
||||||
|
exiled: bool,
|
||||||
|
reason: str | None,
|
||||||
|
moderator_nickname: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Notify participant that their assignment was skipped by organizer"""
|
||||||
|
if not user.telegram_id or not user.notify_moderation:
|
||||||
|
return False
|
||||||
|
|
||||||
|
exile_text = "\n🚫 Игра исключена из вашего пула" if exiled else ""
|
||||||
|
reason_text = f"\n📝 Причина: {reason}" if reason else ""
|
||||||
|
|
||||||
|
message = (
|
||||||
|
f"⏭️ <b>Задание пропущено</b>\n\n"
|
||||||
|
f"Марафон: {marathon_title}\n"
|
||||||
|
f"Игра: {game_title}\n"
|
||||||
|
f"Организатор: {moderator_nickname}"
|
||||||
|
f"{exile_text}"
|
||||||
|
f"{reason_text}\n\n"
|
||||||
|
f"Вы можете крутить колесо заново."
|
||||||
|
)
|
||||||
|
|
||||||
|
return await self.send_message(user.telegram_id, message)
|
||||||
|
|
||||||
|
async def notify_item_granted(
|
||||||
|
self,
|
||||||
|
user,
|
||||||
|
item_name: str,
|
||||||
|
quantity: int,
|
||||||
|
reason: str,
|
||||||
|
admin_nickname: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Notify user that they received an item from admin"""
|
||||||
|
if not user.telegram_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
message = (
|
||||||
|
f"🎁 <b>Вы получили подарок!</b>\n\n"
|
||||||
|
f"Предмет: {item_name}\n"
|
||||||
|
f"Количество: {quantity}\n"
|
||||||
|
f"От: {admin_nickname}\n"
|
||||||
|
f"Причина: {reason}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return await self.send_message(user.telegram_id, message)
|
||||||
|
|
||||||
|
|
||||||
# Global instance
|
# Global instance
|
||||||
telegram_notifier = TelegramNotifier()
|
telegram_notifier = TelegramNotifier()
|
||||||
|
|||||||
4
desktop/.gitignore
vendored
4
desktop/.gitignore
vendored
@@ -14,6 +14,7 @@ npm-debug.log*
|
|||||||
.vscode/
|
.vscode/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
.claude/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -26,3 +27,6 @@ Thumbs.db
|
|||||||
|
|
||||||
# Electron
|
# Electron
|
||||||
*.asar
|
*.asar
|
||||||
|
|
||||||
|
# Lock files (optional - remove if you want to commit)
|
||||||
|
package-lock.json
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "game-marathon-tracker",
|
"name": "game-marathon-tracker",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"description": "Desktop app for tracking game time in Game Marathon",
|
"description": "Desktop app for tracking game time in Game Marathon",
|
||||||
"main": "dist/main/main/index.js",
|
"main": "dist/main/main/index.js",
|
||||||
"author": "Game Marathon",
|
"author": "Game Marathon",
|
||||||
@@ -54,13 +54,20 @@
|
|||||||
"output": "release"
|
"output": "release"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*",
|
"dist/**/*"
|
||||||
"resources/**/*"
|
],
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "resources",
|
||||||
|
"to": "resources"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"win": {
|
"win": {
|
||||||
"target": [
|
"target": [
|
||||||
"nsis",
|
{
|
||||||
"portable"
|
"target": "nsis",
|
||||||
|
"arch": ["x64"]
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"icon": "resources/icon.ico",
|
"icon": "resources/icon.ico",
|
||||||
"signAndEditExecutable": false
|
"signAndEditExecutable": false
|
||||||
@@ -69,7 +76,9 @@
|
|||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
"allowToChangeInstallationDirectory": true,
|
"allowToChangeInstallationDirectory": true,
|
||||||
"createDesktopShortcut": true,
|
"createDesktopShortcut": true,
|
||||||
"createStartMenuShortcut": true
|
"createStartMenuShortcut": true,
|
||||||
|
"runAfterFinish": false,
|
||||||
|
"artifactName": "Game-Marathon-Tracker-Setup-${version}.${ext}"
|
||||||
},
|
},
|
||||||
"publish": {
|
"publish": {
|
||||||
"provider": "github",
|
"provider": "github",
|
||||||
|
|||||||
@@ -34,12 +34,23 @@ const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
|
|||||||
// Prevent multiple instances
|
// Prevent multiple instances
|
||||||
const gotTheLock = app.requestSingleInstanceLock()
|
const gotTheLock = app.requestSingleInstanceLock()
|
||||||
if (!gotTheLock) {
|
if (!gotTheLock) {
|
||||||
app.quit()
|
app.exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Someone tried to run a second instance, focus our window
|
||||||
|
app.on('second-instance', () => {
|
||||||
|
if (mainWindow) {
|
||||||
|
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||||
|
mainWindow.show()
|
||||||
|
mainWindow.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
// __dirname is dist/main/main/ in both dev and prod
|
// In dev: use project resources folder, in prod: use app resources
|
||||||
const iconPath = path.join(__dirname, '../../../resources/icon.ico')
|
const iconPath = isDev
|
||||||
|
? path.join(__dirname, '../../../resources/icon.ico')
|
||||||
|
: path.join(process.resourcesPath, 'resources/icon.ico')
|
||||||
|
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
width: 450,
|
width: 450,
|
||||||
@@ -149,6 +160,11 @@ ipcMain.on('minimize-to-tray', () => {
|
|||||||
mainWindow?.hide()
|
mainWindow?.hide()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.on('close-window', () => {
|
||||||
|
// This triggers the 'close' event handler which checks minimizeToTray setting
|
||||||
|
mainWindow?.close()
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.on('quit-app', () => {
|
ipcMain.on('quit-app', () => {
|
||||||
app.isQuitting = true
|
app.isQuitting = true
|
||||||
app.quit()
|
app.quit()
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ export function setupTray(
|
|||||||
) {
|
) {
|
||||||
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
|
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
|
||||||
|
|
||||||
// In dev: __dirname is dist/main/main/, in prod: same
|
// In dev: use project resources folder, in prod: use app resources
|
||||||
const iconPath = isDev
|
const iconPath = isDev
|
||||||
? path.join(__dirname, '../../../resources/icon.ico')
|
? path.join(__dirname, '../../../resources/icon.ico')
|
||||||
: path.join(__dirname, '../../../resources/icon.ico')
|
: path.join(process.resourcesPath, 'resources/icon.ico')
|
||||||
|
|
||||||
// Create tray icon
|
// Create tray icon
|
||||||
let trayIcon: NativeImage
|
let trayIcon: NativeImage
|
||||||
|
|||||||
@@ -53,15 +53,20 @@ function sendProgressToSplash(percent: number) {
|
|||||||
|
|
||||||
export function setupAutoUpdater(onComplete: () => void) {
|
export function setupAutoUpdater(onComplete: () => void) {
|
||||||
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
|
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
|
||||||
|
let hasCompleted = false
|
||||||
|
|
||||||
|
const safeComplete = () => {
|
||||||
|
if (hasCompleted) return
|
||||||
|
hasCompleted = true
|
||||||
|
closeSplashWindow()
|
||||||
|
onComplete()
|
||||||
|
}
|
||||||
|
|
||||||
// In development, skip update check
|
// In development, skip update check
|
||||||
if (isDev) {
|
if (isDev) {
|
||||||
console.log('[Updater] Skipping update check in development mode')
|
console.log('[Updater] Skipping update check in development mode')
|
||||||
sendStatusToSplash('Режим разработки')
|
sendStatusToSplash('Режим разработки')
|
||||||
setTimeout(() => {
|
setTimeout(safeComplete, 1500)
|
||||||
closeSplashWindow()
|
|
||||||
onComplete()
|
|
||||||
}, 1500)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,24 +74,21 @@ export function setupAutoUpdater(onComplete: () => void) {
|
|||||||
autoUpdater.autoDownload = true
|
autoUpdater.autoDownload = true
|
||||||
autoUpdater.autoInstallOnAppQuit = true
|
autoUpdater.autoInstallOnAppQuit = true
|
||||||
|
|
||||||
// Check for updates
|
// Check for updates (use 'once' to prevent handlers from triggering on manual update checks)
|
||||||
autoUpdater.on('checking-for-update', () => {
|
autoUpdater.once('checking-for-update', () => {
|
||||||
console.log('[Updater] Checking for updates...')
|
console.log('[Updater] Checking for updates...')
|
||||||
sendStatusToSplash('Проверка обновлений...')
|
sendStatusToSplash('Проверка обновлений...')
|
||||||
})
|
})
|
||||||
|
|
||||||
autoUpdater.on('update-available', (info) => {
|
autoUpdater.once('update-available', (info) => {
|
||||||
console.log('[Updater] Update available:', info.version)
|
console.log('[Updater] Update available:', info.version)
|
||||||
sendStatusToSplash(`Найдено обновление v${info.version}`)
|
sendStatusToSplash(`Найдено обновление v${info.version}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
autoUpdater.on('update-not-available', () => {
|
autoUpdater.once('update-not-available', () => {
|
||||||
console.log('[Updater] No updates available')
|
console.log('[Updater] No updates available')
|
||||||
sendStatusToSplash('Актуальная версия')
|
sendStatusToSplash('Актуальная версия')
|
||||||
setTimeout(() => {
|
setTimeout(safeComplete, 1000)
|
||||||
closeSplashWindow()
|
|
||||||
onComplete()
|
|
||||||
}, 1000)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
autoUpdater.on('download-progress', (progress) => {
|
autoUpdater.on('download-progress', (progress) => {
|
||||||
@@ -96,7 +98,7 @@ export function setupAutoUpdater(onComplete: () => void) {
|
|||||||
sendProgressToSplash(percent)
|
sendProgressToSplash(percent)
|
||||||
})
|
})
|
||||||
|
|
||||||
autoUpdater.on('update-downloaded', (info) => {
|
autoUpdater.once('update-downloaded', (info) => {
|
||||||
console.log('[Updater] Update downloaded:', info.version)
|
console.log('[Updater] Update downloaded:', info.version)
|
||||||
sendStatusToSplash('Установка обновления...')
|
sendStatusToSplash('Установка обновления...')
|
||||||
// Install and restart
|
// Install and restart
|
||||||
@@ -105,23 +107,18 @@ export function setupAutoUpdater(onComplete: () => void) {
|
|||||||
}, 1500)
|
}, 1500)
|
||||||
})
|
})
|
||||||
|
|
||||||
autoUpdater.on('error', (error) => {
|
autoUpdater.once('error', (error) => {
|
||||||
console.error('[Updater] Error:', error)
|
console.error('[Updater] Error:', error.message)
|
||||||
sendStatusToSplash('Ошибка проверки обновлений')
|
console.error('[Updater] Error stack:', error.stack)
|
||||||
setTimeout(() => {
|
sendStatusToSplash('Запуск...')
|
||||||
closeSplashWindow()
|
setTimeout(safeComplete, 1500)
|
||||||
onComplete()
|
|
||||||
}, 2000)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Start checking
|
// Start checking
|
||||||
autoUpdater.checkForUpdates().catch((error) => {
|
autoUpdater.checkForUpdates().catch((error) => {
|
||||||
console.error('[Updater] Failed to check for updates:', error)
|
console.error('[Updater] Failed to check for updates:', error)
|
||||||
sendStatusToSplash('Не удалось проверить обновления')
|
sendStatusToSplash('Запуск...')
|
||||||
setTimeout(() => {
|
setTimeout(safeComplete, 1500)
|
||||||
closeSplashWindow()
|
|
||||||
onComplete()
|
|
||||||
}, 2000)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ const electronAPI = {
|
|||||||
|
|
||||||
// Window controls
|
// Window controls
|
||||||
minimizeToTray: (): void => ipcRenderer.send('minimize-to-tray'),
|
minimizeToTray: (): void => ipcRenderer.send('minimize-to-tray'),
|
||||||
|
closeWindow: (): void => ipcRenderer.send('close-window'),
|
||||||
quitApp: (): void => ipcRenderer.send('quit-app'),
|
quitApp: (): void => ipcRenderer.send('quit-app'),
|
||||||
|
|
||||||
// Monitoring control
|
// Monitoring control
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function Layout({ children }: LayoutProps) {
|
|||||||
<Minus className="w-4 h-4 text-gray-400" />
|
<Minus className="w-4 h-4 text-gray-400" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => window.electronAPI.quitApp()}
|
onClick={() => window.electronAPI.closeWindow()}
|
||||||
className="w-8 h-8 flex items-center justify-center hover:bg-red-600 transition-colors"
|
className="w-8 h-8 flex items-center justify-center hover:bg-red-600 transition-colors"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4 text-gray-400" />
|
<X className="w-4 h-4 text-gray-400" />
|
||||||
|
|||||||
@@ -75,24 +75,26 @@ export function DashboardPage() {
|
|||||||
// Check if we should track time: any tracked game is running + active assignment exists
|
// Check if we should track time: any tracked game is running + active assignment exists
|
||||||
const isTrackingAssignment = !!(currentGame && currentAssignment && currentAssignment.status === 'active')
|
const isTrackingAssignment = !!(currentGame && currentAssignment && currentAssignment.status === 'active')
|
||||||
|
|
||||||
// Sync time to server
|
// Track base minutes at session start to avoid re-adding on each sync
|
||||||
|
const baseMinutesRef = useRef<number>(0)
|
||||||
|
|
||||||
|
// Sync time to server - use refs to avoid dependency issues
|
||||||
const doSyncTime = useCallback(async () => {
|
const doSyncTime = useCallback(async () => {
|
||||||
if (!currentAssignment || !isTrackingAssignment) {
|
const assignment = useMarathonStore.getState().currentAssignment
|
||||||
|
if (!assignment || assignment.status !== 'active' || !sessionStartRef.current) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate total minutes: previous tracked + current session
|
// Calculate session duration only
|
||||||
const sessionDuration = sessionStartRef.current
|
const sessionMinutes = Math.floor((Date.now() - sessionStartRef.current) / 60000)
|
||||||
? Math.floor((Date.now() - sessionStartRef.current) / 60000)
|
const totalMinutes = baseMinutesRef.current + sessionMinutes
|
||||||
: 0
|
|
||||||
const totalMinutes = currentAssignment.tracked_time_minutes + sessionDuration
|
|
||||||
|
|
||||||
if (totalMinutes !== lastSyncedMinutesRef.current && totalMinutes > 0) {
|
if (totalMinutes !== lastSyncedMinutesRef.current && totalMinutes > 0) {
|
||||||
console.log(`[Sync] Syncing ${totalMinutes} minutes for assignment ${currentAssignment.id}`)
|
console.log(`[Sync] Syncing ${totalMinutes} minutes for assignment ${assignment.id} (base: ${baseMinutesRef.current}, session: ${sessionMinutes})`)
|
||||||
await syncTime(totalMinutes)
|
await syncTime(totalMinutes)
|
||||||
lastSyncedMinutesRef.current = totalMinutes
|
lastSyncedMinutesRef.current = totalMinutes
|
||||||
}
|
}
|
||||||
}, [currentAssignment, isTrackingAssignment, syncTime])
|
}, [syncTime])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTrackedGames()
|
loadTrackedGames()
|
||||||
@@ -134,16 +136,17 @@ export function DashboardPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let localTimerInterval: NodeJS.Timeout | null = null
|
let localTimerInterval: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
if (isTrackingAssignment) {
|
if (isTrackingAssignment && currentAssignment) {
|
||||||
// Start session if not already started
|
// Start session if not already started
|
||||||
if (!sessionStartRef.current) {
|
if (!sessionStartRef.current) {
|
||||||
sessionStartRef.current = Date.now()
|
sessionStartRef.current = Date.now()
|
||||||
|
// Store base minutes at session start
|
||||||
|
baseMinutesRef.current = currentAssignment.tracked_time_minutes || 0
|
||||||
|
lastSyncedMinutesRef.current = baseMinutesRef.current
|
||||||
|
console.log(`[Sync] Session started, base minutes: ${baseMinutesRef.current}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync immediately when game starts
|
// Setup periodic sync every 60 seconds (don't sync immediately to avoid loops)
|
||||||
doSyncTime()
|
|
||||||
|
|
||||||
// Setup periodic sync every 60 seconds
|
|
||||||
syncIntervalRef.current = setInterval(() => {
|
syncIntervalRef.current = setInterval(() => {
|
||||||
doSyncTime()
|
doSyncTime()
|
||||||
}, 60000)
|
}, 60000)
|
||||||
@@ -157,12 +160,15 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Do final sync when game stops
|
// Do final sync when game stops
|
||||||
if (syncIntervalRef.current) {
|
if (sessionStartRef.current) {
|
||||||
doSyncTime()
|
doSyncTime()
|
||||||
|
}
|
||||||
|
if (syncIntervalRef.current) {
|
||||||
clearInterval(syncIntervalRef.current)
|
clearInterval(syncIntervalRef.current)
|
||||||
syncIntervalRef.current = null
|
syncIntervalRef.current = null
|
||||||
sessionStartRef.current = null
|
|
||||||
}
|
}
|
||||||
|
sessionStartRef.current = null
|
||||||
|
baseMinutesRef.current = 0
|
||||||
setLocalSessionSeconds(0)
|
setLocalSessionSeconds(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +181,9 @@ export function DashboardPage() {
|
|||||||
clearInterval(localTimerInterval)
|
clearInterval(localTimerInterval)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isTrackingAssignment, doSyncTime])
|
// Note: doSyncTime is intentionally excluded to avoid infinite loops
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isTrackingAssignment])
|
||||||
|
|
||||||
// Toggle monitoring
|
// Toggle monitoring
|
||||||
const toggleMonitoring = async () => {
|
const toggleMonitoring = async () => {
|
||||||
@@ -222,9 +230,11 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
// Playthrough assignment
|
// Playthrough assignment
|
||||||
if (assignment.is_playthrough && assignment.playthrough_info) {
|
if (assignment.is_playthrough && assignment.playthrough_info) {
|
||||||
// Use localSessionSeconds for live display (updates every second)
|
// When actively tracking: use baseMinutesRef (set at session start) + current session
|
||||||
|
// Otherwise: use tracked_time_minutes from assignment
|
||||||
|
const baseMinutes = isTrackingAssignment ? baseMinutesRef.current : assignment.tracked_time_minutes
|
||||||
const sessionSeconds = isTrackingAssignment ? localSessionSeconds : 0
|
const sessionSeconds = isTrackingAssignment ? localSessionSeconds : 0
|
||||||
const totalSeconds = (assignment.tracked_time_minutes * 60) + sessionSeconds
|
const totalSeconds = (baseMinutes * 60) + sessionSeconds
|
||||||
const totalMinutes = Math.floor(totalSeconds / 60)
|
const totalMinutes = Math.floor(totalSeconds / 60)
|
||||||
const trackedHours = totalMinutes / 60
|
const trackedHours = totalMinutes / 60
|
||||||
const estimatedPoints = Math.floor(trackedHours * 30)
|
const estimatedPoints = Math.floor(trackedHours * 30)
|
||||||
|
|||||||
789
docs/tz-skip-exile-moderation.md
Normal file
789
docs/tz-skip-exile-moderation.md
Normal file
@@ -0,0 +1,789 @@
|
|||||||
|
# ТЗ: Скип с изгнанием, модерация и выдача предметов
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
Три связанные фичи:
|
||||||
|
1. **Скип с изгнанием** — новый консамбл, который скипает задание И навсегда исключает игру из пула участника
|
||||||
|
2. **Модерация марафона** — организаторы могут скипать задания у участников (обычный скип / скип с изгнанием)
|
||||||
|
3. **Выдача предметов админами** — UI для системных администраторов для выдачи предметов пользователям
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Скип с изгнанием (SKIP_EXILE)
|
||||||
|
|
||||||
|
### 1.1 Концепция
|
||||||
|
|
||||||
|
| Тип скипа | Штраф | Стрик | Игра может выпасть снова |
|
||||||
|
|-----------|-------|-------|--------------------------|
|
||||||
|
| Обычный DROP | Да (прогрессивный) | Сбрасывается | Да (для challenges) / Нет (для playthrough) |
|
||||||
|
| SKIP (консамбл) | Нет | Сохраняется | Да (для challenges) / Нет (для playthrough) |
|
||||||
|
| **SKIP_EXILE** | Нет | Сохраняется | **Нет** |
|
||||||
|
|
||||||
|
### 1.2 Backend
|
||||||
|
|
||||||
|
#### Новая модель: ExiledGame
|
||||||
|
```python
|
||||||
|
# backend/app/models/exiled_game.py
|
||||||
|
class ExiledGame(Base):
|
||||||
|
__tablename__ = "exiled_games"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("participant_id", "game_id", name="unique_participant_game_exile"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: int (PK)
|
||||||
|
participant_id: int (FK -> participants.id, ondelete=CASCADE)
|
||||||
|
game_id: int (FK -> games.id, ondelete=CASCADE)
|
||||||
|
assignment_id: int | None (FK -> assignments.id) # Какое задание было при изгнании
|
||||||
|
exiled_at: datetime
|
||||||
|
exiled_by: str # "user" | "organizer" | "admin"
|
||||||
|
reason: str | None # Опциональная причина
|
||||||
|
|
||||||
|
# История восстановления (soft-delete pattern)
|
||||||
|
is_active: bool = True # False = игра возвращена в пул
|
||||||
|
unexiled_at: datetime | None
|
||||||
|
unexiled_by: str | None # "organizer" | "admin"
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Примечание**: При восстановлении игры запись НЕ удаляется, а помечается `is_active=False`.
|
||||||
|
> Это сохраняет историю изгнаний для аналитики и разрешения споров.
|
||||||
|
|
||||||
|
#### Новый ConsumableType
|
||||||
|
```python
|
||||||
|
# backend/app/models/shop.py
|
||||||
|
class ConsumableType(str, Enum):
|
||||||
|
SKIP = "skip"
|
||||||
|
SKIP_EXILE = "skip_exile" # NEW
|
||||||
|
BOOST = "boost"
|
||||||
|
WILD_CARD = "wild_card"
|
||||||
|
LUCKY_DICE = "lucky_dice"
|
||||||
|
COPYCAT = "copycat"
|
||||||
|
UNDO = "undo"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Создание предмета в магазине
|
||||||
|
```python
|
||||||
|
# Предмет добавляется через админку или миграцию
|
||||||
|
ShopItem(
|
||||||
|
item_type="consumable",
|
||||||
|
code="skip_exile",
|
||||||
|
name="Скип с изгнанием",
|
||||||
|
description="Пропустить текущее задание без штрафа. Игра навсегда исключается из вашего пула.",
|
||||||
|
price=150, # Дороже обычного скипа (50)
|
||||||
|
rarity="rare",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Сервис: use_skip_exile
|
||||||
|
```python
|
||||||
|
# backend/app/services/consumables.py
|
||||||
|
|
||||||
|
async def use_skip_exile(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
participant: Participant,
|
||||||
|
marathon: Marathon,
|
||||||
|
assignment: Assignment,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Skip assignment AND exile the game permanently.
|
||||||
|
|
||||||
|
- No streak loss
|
||||||
|
- No drop penalty
|
||||||
|
- Game is permanently excluded from participant's pool
|
||||||
|
"""
|
||||||
|
# Проверки как у обычного skip
|
||||||
|
if not marathon.allow_skips:
|
||||||
|
raise HTTPException(400, "Skips not allowed")
|
||||||
|
|
||||||
|
if marathon.max_skips_per_participant is not None:
|
||||||
|
if participant.skips_used >= marathon.max_skips_per_participant:
|
||||||
|
raise HTTPException(400, "Skip limit reached")
|
||||||
|
|
||||||
|
if assignment.status != AssignmentStatus.ACTIVE.value:
|
||||||
|
raise HTTPException(400, "Can only skip active assignments")
|
||||||
|
|
||||||
|
# Получаем game_id
|
||||||
|
if assignment.is_playthrough:
|
||||||
|
game_id = assignment.game_id
|
||||||
|
else:
|
||||||
|
game_id = assignment.challenge.game_id
|
||||||
|
|
||||||
|
# Consume from inventory
|
||||||
|
item = await self._consume_item(db, user, ConsumableType.SKIP_EXILE.value)
|
||||||
|
|
||||||
|
# Mark assignment as dropped (без штрафа)
|
||||||
|
assignment.status = AssignmentStatus.DROPPED.value
|
||||||
|
assignment.completed_at = datetime.utcnow()
|
||||||
|
|
||||||
|
# Track skip usage
|
||||||
|
participant.skips_used += 1
|
||||||
|
|
||||||
|
# НОВОЕ: Добавляем игру в exiled
|
||||||
|
exiled = ExiledGame(
|
||||||
|
participant_id=participant.id,
|
||||||
|
game_id=game_id,
|
||||||
|
exiled_by="user",
|
||||||
|
)
|
||||||
|
db.add(exiled)
|
||||||
|
|
||||||
|
# Log usage
|
||||||
|
usage = ConsumableUsage(...)
|
||||||
|
db.add(usage)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"skipped": True,
|
||||||
|
"exiled": True,
|
||||||
|
"game_id": game_id,
|
||||||
|
"penalty": 0,
|
||||||
|
"streak_preserved": True,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Изменение get_available_games_for_participant
|
||||||
|
```python
|
||||||
|
# backend/app/api/v1/games.py
|
||||||
|
|
||||||
|
async def get_available_games_for_participant(...):
|
||||||
|
# ... existing code ...
|
||||||
|
|
||||||
|
# НОВОЕ: Получаем изгнанные игры
|
||||||
|
exiled_result = await db.execute(
|
||||||
|
select(ExiledGame.game_id)
|
||||||
|
.where(ExiledGame.participant_id == participant.id)
|
||||||
|
)
|
||||||
|
exiled_game_ids = set(exiled_result.scalars().all())
|
||||||
|
|
||||||
|
# Фильтруем доступные игры
|
||||||
|
available_games = []
|
||||||
|
for game in games_with_content:
|
||||||
|
# НОВОЕ: Исключаем изгнанные игры
|
||||||
|
if game.id in exiled_game_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if game.game_type == GameType.PLAYTHROUGH.value:
|
||||||
|
if game.id not in finished_playthrough_game_ids:
|
||||||
|
available_games.append(game)
|
||||||
|
else:
|
||||||
|
# ...existing logic...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 Frontend
|
||||||
|
|
||||||
|
#### Обновление UI использования консамблов
|
||||||
|
- В `PlayPage.tsx` добавить кнопку "Скип с изгнанием" рядом с обычным скипом
|
||||||
|
- Показывать предупреждение: "Игра будет навсегда исключена из вашего пула"
|
||||||
|
- В инвентаре показывать оба типа скипов отдельно
|
||||||
|
|
||||||
|
### 1.4 API Endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /shop/use
|
||||||
|
Body: {
|
||||||
|
"item_code": "skip_exile",
|
||||||
|
"marathon_id": 123,
|
||||||
|
"assignment_id": 456
|
||||||
|
}
|
||||||
|
|
||||||
|
Response: {
|
||||||
|
"success": true,
|
||||||
|
"remaining_quantity": 2,
|
||||||
|
"effect_description": "Задание пропущено, игра изгнана",
|
||||||
|
"effect_data": {
|
||||||
|
"skipped": true,
|
||||||
|
"exiled": true,
|
||||||
|
"game_id": 789,
|
||||||
|
"penalty": 0,
|
||||||
|
"streak_preserved": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Модерация марафона (скипы организаторами)
|
||||||
|
|
||||||
|
### 2.1 Концепция
|
||||||
|
|
||||||
|
Организаторы марафона могут скипать задания у участников:
|
||||||
|
- **Скип** — пропустить задание без штрафа (игра может выпасть снова)
|
||||||
|
- **Скип с изгнанием** — пропустить и исключить игру из пула участника
|
||||||
|
|
||||||
|
Причины использования:
|
||||||
|
- Участник просит пропустить игру (технические проблемы, неподходящая игра)
|
||||||
|
- Модерация спорных ситуаций
|
||||||
|
- Исправление ошибок
|
||||||
|
|
||||||
|
### 2.2 Backend
|
||||||
|
|
||||||
|
#### Новые эндпоинты
|
||||||
|
```python
|
||||||
|
# backend/app/api/v1/marathons.py
|
||||||
|
|
||||||
|
@router.post("/{marathon_id}/participants/{user_id}/skip-assignment")
|
||||||
|
async def organizer_skip_assignment(
|
||||||
|
marathon_id: int,
|
||||||
|
user_id: int,
|
||||||
|
data: OrganizerSkipRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Организатор скипает текущее задание участника.
|
||||||
|
|
||||||
|
Body:
|
||||||
|
exile: bool = False # Если true — скип с изгнанием
|
||||||
|
reason: str | None # Причина (опционально)
|
||||||
|
"""
|
||||||
|
await require_organizer(db, current_user, marathon_id)
|
||||||
|
|
||||||
|
# Получаем участника
|
||||||
|
participant = await get_participant_by_user_id(db, user_id, marathon_id)
|
||||||
|
if not participant:
|
||||||
|
raise HTTPException(404, "Participant not found")
|
||||||
|
|
||||||
|
# Получаем активное задание
|
||||||
|
assignment = await get_active_assignment(db, participant.id)
|
||||||
|
if not assignment:
|
||||||
|
raise HTTPException(400, "No active assignment")
|
||||||
|
|
||||||
|
# Определяем game_id
|
||||||
|
if assignment.is_playthrough:
|
||||||
|
game_id = assignment.game_id
|
||||||
|
else:
|
||||||
|
game_id = assignment.challenge.game_id
|
||||||
|
|
||||||
|
# Скипаем
|
||||||
|
assignment.status = AssignmentStatus.DROPPED.value
|
||||||
|
assignment.completed_at = datetime.utcnow()
|
||||||
|
|
||||||
|
# НЕ увеличиваем skips_used (это модераторский скип, не консамбл)
|
||||||
|
# НЕ сбрасываем стрик
|
||||||
|
# НЕ увеличиваем drop_count
|
||||||
|
|
||||||
|
# Если exile — добавляем в exiled
|
||||||
|
if data.exile:
|
||||||
|
exiled = ExiledGame(
|
||||||
|
participant_id=participant.id,
|
||||||
|
game_id=game_id,
|
||||||
|
exiled_by="organizer",
|
||||||
|
reason=data.reason,
|
||||||
|
)
|
||||||
|
db.add(exiled)
|
||||||
|
|
||||||
|
# Логируем в Activity
|
||||||
|
activity = Activity(
|
||||||
|
marathon_id=marathon_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
type=ActivityType.MODERATION.value,
|
||||||
|
data={
|
||||||
|
"action": "skip_assignment",
|
||||||
|
"target_user_id": user_id,
|
||||||
|
"assignment_id": assignment.id,
|
||||||
|
"game_id": game_id,
|
||||||
|
"exile": data.exile,
|
||||||
|
"reason": data.reason,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.add(activity)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {"success": True, "exiled": data.exile}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{marathon_id}/participants/{user_id}/exiled-games")
|
||||||
|
async def get_participant_exiled_games(
|
||||||
|
marathon_id: int,
|
||||||
|
user_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Список изгнанных игр участника (для организаторов)"""
|
||||||
|
await require_organizer(db, current_user, marathon_id)
|
||||||
|
# ...
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{marathon_id}/participants/{user_id}/exiled-games/{game_id}")
|
||||||
|
async def remove_exiled_game(
|
||||||
|
marathon_id: int,
|
||||||
|
user_id: int,
|
||||||
|
game_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Убрать игру из изгнанных (вернуть в пул)"""
|
||||||
|
await require_organizer(db, current_user, marathon_id)
|
||||||
|
# ...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Схемы
|
||||||
|
```python
|
||||||
|
# backend/app/schemas/marathon.py
|
||||||
|
|
||||||
|
class OrganizerSkipRequest(BaseModel):
|
||||||
|
exile: bool = False
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
class ExiledGameResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
game_id: int
|
||||||
|
game_title: str
|
||||||
|
exiled_at: datetime
|
||||||
|
exiled_by: str
|
||||||
|
reason: str | None
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Frontend
|
||||||
|
|
||||||
|
#### Страница участников марафона
|
||||||
|
В списке участников (`MarathonPage.tsx` или отдельная страница модерации):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Для каждого участника с активным заданием показываем кнопки:
|
||||||
|
<button onClick={() => skipAssignment(userId, false)}>
|
||||||
|
Скип
|
||||||
|
</button>
|
||||||
|
<button onClick={() => skipAssignment(userId, true)}>
|
||||||
|
Скип с изгнанием
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Модальное окно скипа
|
||||||
|
```tsx
|
||||||
|
<Modal>
|
||||||
|
<h2>Скип задания у {participant.nickname}</h2>
|
||||||
|
<p>Текущее задание: {assignment.game.title}</p>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" checked={exile} onChange={...} />
|
||||||
|
Изгнать игру (не будет выпадать снова)
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<textarea placeholder="Причина (опционально)" />
|
||||||
|
|
||||||
|
<button>Подтвердить</button>
|
||||||
|
</Modal>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 Telegram уведомления
|
||||||
|
|
||||||
|
При модераторском скипе отправляем уведомление участнику:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/app/services/telegram_notifier.py
|
||||||
|
|
||||||
|
async def notify_assignment_skipped_by_moderator(
|
||||||
|
user: User,
|
||||||
|
marathon_title: str,
|
||||||
|
game_title: str,
|
||||||
|
exiled: bool,
|
||||||
|
reason: str | None,
|
||||||
|
moderator_nickname: str,
|
||||||
|
):
|
||||||
|
"""Уведомление о скипе задания организатором"""
|
||||||
|
if not user.telegram_id or not user.notify_moderation:
|
||||||
|
return
|
||||||
|
|
||||||
|
exile_text = "\n🚫 Игра исключена из вашего пула" if exiled else ""
|
||||||
|
reason_text = f"\n📝 Причина: {reason}" if reason else ""
|
||||||
|
|
||||||
|
message = f"""⏭️ <b>Задание пропущено</b>
|
||||||
|
|
||||||
|
Марафон: {marathon_title}
|
||||||
|
Игра: {game_title}
|
||||||
|
Организатор: {moderator_nickname}{exile_text}{reason_text}
|
||||||
|
|
||||||
|
Вы можете крутить колесо заново."""
|
||||||
|
|
||||||
|
await self._send_message(user.telegram_id, message)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Добавить поле notify_moderation в User
|
||||||
|
```python
|
||||||
|
# backend/app/models/user.py
|
||||||
|
class User(Base):
|
||||||
|
# ... existing fields ...
|
||||||
|
notify_moderation: bool = True # Уведомления о действиях модераторов
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Интеграция в эндпоинт
|
||||||
|
```python
|
||||||
|
# В organizer_skip_assignment после db.commit():
|
||||||
|
await telegram_notifier.notify_assignment_skipped_by_moderator(
|
||||||
|
user=target_user,
|
||||||
|
marathon_title=marathon.title,
|
||||||
|
game_title=game.title,
|
||||||
|
exiled=data.exile,
|
||||||
|
reason=data.reason,
|
||||||
|
moderator_nickname=current_user.nickname,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Выдача предметов админами
|
||||||
|
|
||||||
|
### 3.1 Backend
|
||||||
|
|
||||||
|
#### Новые эндпоинты
|
||||||
|
```python
|
||||||
|
# backend/app/api/v1/shop.py
|
||||||
|
|
||||||
|
@router.post("/admin/users/{user_id}/items/grant", response_model=MessageResponse)
|
||||||
|
async def admin_grant_item(
|
||||||
|
user_id: int,
|
||||||
|
data: AdminGrantItemRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Выдать предмет пользователю (admin only).
|
||||||
|
|
||||||
|
Body:
|
||||||
|
item_id: int # ID предмета в магазине
|
||||||
|
quantity: int = 1 # Количество (для консамблов)
|
||||||
|
reason: str # Причина выдачи
|
||||||
|
"""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
# Получаем пользователя
|
||||||
|
user = await get_user_by_id(db, user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(404, "User not found")
|
||||||
|
|
||||||
|
# Получаем предмет
|
||||||
|
item = await shop_service.get_item_by_id(db, data.item_id)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(404, "Item not found")
|
||||||
|
|
||||||
|
# Проверяем quantity для не-консамблов
|
||||||
|
if item.item_type != "consumable" and data.quantity > 1:
|
||||||
|
raise HTTPException(400, "Non-consumables can only have quantity 1")
|
||||||
|
|
||||||
|
# Проверяем, есть ли уже такой предмет
|
||||||
|
existing = await db.execute(
|
||||||
|
select(UserInventory)
|
||||||
|
.where(
|
||||||
|
UserInventory.user_id == user_id,
|
||||||
|
UserInventory.item_id == item.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
inv_item = existing.scalar_one_or_none()
|
||||||
|
|
||||||
|
if inv_item:
|
||||||
|
if item.item_type == "consumable":
|
||||||
|
inv_item.quantity += data.quantity
|
||||||
|
else:
|
||||||
|
raise HTTPException(400, "User already owns this item")
|
||||||
|
else:
|
||||||
|
inv_item = UserInventory(
|
||||||
|
user_id=user_id,
|
||||||
|
item_id=item.id,
|
||||||
|
quantity=data.quantity if item.item_type == "consumable" else 1,
|
||||||
|
)
|
||||||
|
db.add(inv_item)
|
||||||
|
|
||||||
|
# Логируем
|
||||||
|
log = AdminLog(
|
||||||
|
admin_id=current_user.id,
|
||||||
|
action="ITEM_GRANT",
|
||||||
|
target_type="user",
|
||||||
|
target_id=user_id,
|
||||||
|
details={
|
||||||
|
"item_id": item.id,
|
||||||
|
"item_name": item.name,
|
||||||
|
"quantity": data.quantity,
|
||||||
|
"reason": data.reason,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return MessageResponse(
|
||||||
|
message=f"Granted {data.quantity}x {item.name} to {user.nickname}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/users/{user_id}/inventory", response_model=list[InventoryItemResponse])
|
||||||
|
async def admin_get_user_inventory(
|
||||||
|
user_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Получить инвентарь пользователя (admin only)"""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
# ...
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/admin/users/{user_id}/inventory/{inventory_id}", response_model=MessageResponse)
|
||||||
|
async def admin_remove_item(
|
||||||
|
user_id: int,
|
||||||
|
inventory_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Удалить предмет из инвентаря пользователя (admin only)"""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
# ...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Схемы
|
||||||
|
```python
|
||||||
|
class AdminGrantItemRequest(BaseModel):
|
||||||
|
item_id: int
|
||||||
|
quantity: int = 1
|
||||||
|
reason: str
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Frontend
|
||||||
|
|
||||||
|
#### Новая страница: AdminItemsPage
|
||||||
|
`frontend/src/pages/admin/AdminItemsPage.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export function AdminItemsPage() {
|
||||||
|
const [users, setUsers] = useState<User[]>([])
|
||||||
|
const [items, setItems] = useState<ShopItem[]>([])
|
||||||
|
const [selectedUser, setSelectedUser] = useState<User | null>(null)
|
||||||
|
const [grantModal, setGrantModal] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Выдача предметов</h1>
|
||||||
|
|
||||||
|
{/* Поиск пользователя */}
|
||||||
|
<UserSearch onSelect={setSelectedUser} />
|
||||||
|
|
||||||
|
{selectedUser && (
|
||||||
|
<>
|
||||||
|
{/* Информация о пользователе */}
|
||||||
|
<UserCard user={selectedUser} />
|
||||||
|
|
||||||
|
{/* Инвентарь пользователя */}
|
||||||
|
<h2>Инвентарь</h2>
|
||||||
|
<UserInventoryList userId={selectedUser.id} />
|
||||||
|
|
||||||
|
{/* Кнопка выдачи */}
|
||||||
|
<button onClick={() => setGrantModal(true)}>
|
||||||
|
Выдать предмет
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Модалка выдачи */}
|
||||||
|
<GrantItemModal
|
||||||
|
isOpen={grantModal}
|
||||||
|
user={selectedUser}
|
||||||
|
items={items}
|
||||||
|
onClose={() => setGrantModal(false)}
|
||||||
|
onGrant={handleGrant}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Компонент GrantItemModal
|
||||||
|
```tsx
|
||||||
|
function GrantItemModal({ isOpen, user, items, onClose, onGrant }) {
|
||||||
|
const [itemId, setItemId] = useState<number | null>(null)
|
||||||
|
const [quantity, setQuantity] = useState(1)
|
||||||
|
const [reason, setReason] = useState("")
|
||||||
|
|
||||||
|
const selectedItem = items.find(i => i.id === itemId)
|
||||||
|
const isConsumable = selectedItem?.item_type === "consumable"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose}>
|
||||||
|
<h2>Выдать предмет: {user?.nickname}</h2>
|
||||||
|
|
||||||
|
{/* Выбор предмета */}
|
||||||
|
<select value={itemId} onChange={e => setItemId(Number(e.target.value))}>
|
||||||
|
<option value="">Выберите предмет</option>
|
||||||
|
{items.map(item => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.name} ({item.item_type})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Количество (только для консамблов) */}
|
||||||
|
{isConsumable && (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={quantity}
|
||||||
|
onChange={e => setQuantity(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Причина */}
|
||||||
|
<textarea
|
||||||
|
value={reason}
|
||||||
|
onChange={e => setReason(e.target.value)}
|
||||||
|
placeholder="Причина выдачи (обязательно)"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => onGrant({ itemId, quantity, reason })}
|
||||||
|
disabled={!itemId || !reason}
|
||||||
|
>
|
||||||
|
Выдать
|
||||||
|
</button>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Добавление в роутер
|
||||||
|
```tsx
|
||||||
|
// frontend/src/App.tsx
|
||||||
|
import { AdminItemsPage } from '@/pages/admin/AdminItemsPage'
|
||||||
|
|
||||||
|
// В админских роутах:
|
||||||
|
<Route path="items" element={<AdminItemsPage />} />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Добавление в меню админки
|
||||||
|
```tsx
|
||||||
|
// frontend/src/pages/admin/AdminLayout.tsx
|
||||||
|
const adminLinks = [
|
||||||
|
{ path: '/admin', label: 'Дашборд' },
|
||||||
|
{ path: '/admin/users', label: 'Пользователи' },
|
||||||
|
{ path: '/admin/marathons', label: 'Марафоны' },
|
||||||
|
{ path: '/admin/items', label: 'Предметы' }, // NEW
|
||||||
|
{ path: '/admin/promo', label: 'Промокоды' },
|
||||||
|
// ...
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Миграции
|
||||||
|
|
||||||
|
### 4.1 Создание таблицы exiled_games
|
||||||
|
```python
|
||||||
|
# backend/alembic/versions/XXX_add_exiled_games.py
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
'exiled_games',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True),
|
||||||
|
sa.Column('participant_id', sa.Integer(), sa.ForeignKey('participants.id', ondelete='CASCADE'), nullable=False),
|
||||||
|
sa.Column('game_id', sa.Integer(), sa.ForeignKey('games.id', ondelete='CASCADE'), nullable=False),
|
||||||
|
sa.Column('assignment_id', sa.Integer(), sa.ForeignKey('assignments.id', ondelete='SET NULL'), nullable=True),
|
||||||
|
sa.Column('exiled_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column('exiled_by', sa.String(20), nullable=False), # user, organizer, admin
|
||||||
|
sa.Column('reason', sa.String(500), nullable=True),
|
||||||
|
# История восстановления
|
||||||
|
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
|
||||||
|
sa.Column('unexiled_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('unexiled_by', sa.String(20), nullable=True),
|
||||||
|
sa.UniqueConstraint('participant_id', 'game_id', name='unique_participant_game_exile'),
|
||||||
|
)
|
||||||
|
op.create_index('ix_exiled_games_participant_id', 'exiled_games', ['participant_id'])
|
||||||
|
op.create_index('ix_exiled_games_active', 'exiled_games', ['participant_id', 'is_active'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_table('exiled_games')
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Добавление поля notify_moderation в users
|
||||||
|
```python
|
||||||
|
def upgrade():
|
||||||
|
op.add_column('users', sa.Column('notify_moderation', sa.Boolean(), server_default='true', nullable=False))
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column('users', 'notify_moderation')
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Добавление предмета skip_exile
|
||||||
|
```python
|
||||||
|
# Можно через миграцию или вручную через админку
|
||||||
|
# Если через миграцию:
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ... create table ...
|
||||||
|
|
||||||
|
# Добавляем предмет в магазин
|
||||||
|
op.execute("""
|
||||||
|
INSERT INTO shop_items (item_type, code, name, description, price, rarity, is_active, created_at)
|
||||||
|
VALUES (
|
||||||
|
'consumable',
|
||||||
|
'skip_exile',
|
||||||
|
'Скип с изгнанием',
|
||||||
|
'Пропустить текущее задание без штрафа. Игра навсегда исключается из вашего пула.',
|
||||||
|
150,
|
||||||
|
'rare',
|
||||||
|
true,
|
||||||
|
NOW()
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Чеклист реализации
|
||||||
|
|
||||||
|
### Backend — Модели и миграции
|
||||||
|
- [ ] Создать модель ExiledGame (с полями assignment_id, is_active, unexiled_at, unexiled_by)
|
||||||
|
- [ ] Добавить поле notify_moderation в User
|
||||||
|
- [ ] Добавить ConsumableType.SKIP_EXILE
|
||||||
|
- [ ] Написать миграцию для exiled_games
|
||||||
|
- [ ] Написать миграцию для notify_moderation
|
||||||
|
- [ ] Добавить предмет skip_exile в магазин
|
||||||
|
|
||||||
|
### Backend — Скип с изгнанием
|
||||||
|
- [ ] Реализовать use_skip_exile в ConsumablesService
|
||||||
|
- [ ] Обновить get_available_games_for_participant (фильтр по is_active=True)
|
||||||
|
- [ ] Добавить обработку skip_exile в POST /shop/use
|
||||||
|
|
||||||
|
### Backend — Модерация
|
||||||
|
- [ ] Добавить эндпоинт POST /{marathon_id}/participants/{user_id}/skip-assignment
|
||||||
|
- [ ] Добавить эндпоинт GET /{marathon_id}/participants/{user_id}/exiled-games
|
||||||
|
- [ ] Добавить эндпоинт POST /{marathon_id}/participants/{user_id}/exiled-games/{game_id}/restore
|
||||||
|
- [ ] Добавить notify_assignment_skipped_by_moderator в telegram_notifier
|
||||||
|
|
||||||
|
### Backend — Админка предметов
|
||||||
|
- [ ] Добавить эндпоинт POST /shop/admin/users/{user_id}/items/grant
|
||||||
|
- [ ] Добавить эндпоинт GET /shop/admin/users/{user_id}/inventory
|
||||||
|
- [ ] Добавить эндпоинт DELETE /shop/admin/users/{user_id}/inventory/{inventory_id}
|
||||||
|
|
||||||
|
### Frontend — Игрок
|
||||||
|
- [ ] Добавить кнопку "Скип с изгнанием" в PlayPage
|
||||||
|
- [ ] Добавить чекбокс notify_moderation в настройках профиля
|
||||||
|
|
||||||
|
### Frontend — Админка
|
||||||
|
- [ ] Создать AdminItemsPage
|
||||||
|
- [ ] Добавить GrantItemModal
|
||||||
|
- [ ] Добавить роут /admin/items
|
||||||
|
- [ ] Добавить пункт меню в AdminLayout
|
||||||
|
|
||||||
|
### Frontend — Модерация марафона
|
||||||
|
- [ ] Создать UI модерации для организаторов (скип заданий)
|
||||||
|
- [ ] Добавить список изгнанных игр участника
|
||||||
|
- [ ] Добавить кнопку восстановления игры в пул
|
||||||
|
|
||||||
|
### Тестирование
|
||||||
|
- [ ] Тест: use_skip_exile корректно исключает игру
|
||||||
|
- [ ] Тест: изгнанная игра не выпадает при спине
|
||||||
|
- [ ] Тест: восстановленная игра (is_active=False) снова выпадает
|
||||||
|
- [ ] Тест: организатор может скипать задания
|
||||||
|
- [ ] Тест: Telegram уведомление отправляется при модераторском скипе
|
||||||
|
- [ ] Тест: админ может выдавать предметы
|
||||||
|
- [ ] Тест: лимиты скипов работают корректно
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Вопросы для обсуждения
|
||||||
|
|
||||||
|
1. **Лимиты изгнания**: Нужен ли лимит на количество изгнанных игр у участника?
|
||||||
|
2. **Отмена изгнания**: Может ли участник сам отменить изгнание? Или только организатор?
|
||||||
|
3. **Стоимость**: Текущая цена skip_exile = 150 монет (обычный skip = 50). Подходит?
|
||||||
|
4. **Телеграм уведомления**: Нужны ли уведомления участнику при модераторском скипе?
|
||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
AdminBroadcastPage,
|
AdminBroadcastPage,
|
||||||
AdminContentPage,
|
AdminContentPage,
|
||||||
AdminPromoCodesPage,
|
AdminPromoCodesPage,
|
||||||
|
AdminGrantItemPage,
|
||||||
} from '@/pages/admin'
|
} from '@/pages/admin'
|
||||||
|
|
||||||
// Protected route wrapper
|
// Protected route wrapper
|
||||||
@@ -241,6 +242,7 @@ function App() {
|
|||||||
>
|
>
|
||||||
<Route index element={<AdminDashboardPage />} />
|
<Route index element={<AdminDashboardPage />} />
|
||||||
<Route path="users" element={<AdminUsersPage />} />
|
<Route path="users" element={<AdminUsersPage />} />
|
||||||
|
<Route path="users/:userId/grant-item" element={<AdminGrantItemPage />} />
|
||||||
<Route path="marathons" element={<AdminMarathonsPage />} />
|
<Route path="marathons" element={<AdminMarathonsPage />} />
|
||||||
<Route path="promo" element={<AdminPromoCodesPage />} />
|
<Route path="promo" element={<AdminPromoCodesPage />} />
|
||||||
<Route path="logs" element={<AdminLogsPage />} />
|
<Route path="logs" element={<AdminLogsPage />} />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import client from './client'
|
import client from './client'
|
||||||
import type { Marathon, MarathonListItem, MarathonPublicInfo, MarathonUpdate, LeaderboardEntry, ParticipantWithUser, ParticipantRole, GameProposalMode, MarathonDispute } from '@/types'
|
import type { Marathon, MarathonListItem, MarathonPublicInfo, MarathonUpdate, LeaderboardEntry, ParticipantWithUser, ParticipantRole, GameProposalMode, MarathonDispute, ExiledGame } from '@/types'
|
||||||
|
|
||||||
export interface CreateMarathonData {
|
export interface CreateMarathonData {
|
||||||
title: string
|
title: string
|
||||||
@@ -112,4 +112,36 @@ export const marathonsApi = {
|
|||||||
)
|
)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// === Moderation ===
|
||||||
|
|
||||||
|
// Skip participant's assignment (organizer only)
|
||||||
|
skipParticipantAssignment: async (
|
||||||
|
marathonId: number,
|
||||||
|
userId: number,
|
||||||
|
exile: boolean = false,
|
||||||
|
reason?: string
|
||||||
|
): Promise<{ message: string }> => {
|
||||||
|
const response = await client.post<{ message: string }>(
|
||||||
|
`/marathons/${marathonId}/participants/${userId}/skip-assignment`,
|
||||||
|
{ exile, reason }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get participant's exiled games (organizer only)
|
||||||
|
getExiledGames: async (marathonId: number, userId: number): Promise<ExiledGame[]> => {
|
||||||
|
const response = await client.get<ExiledGame[]>(
|
||||||
|
`/marathons/${marathonId}/participants/${userId}/exiled-games`
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Restore exiled game (organizer only)
|
||||||
|
restoreExiledGame: async (marathonId: number, userId: number, gameId: number): Promise<{ message: string }> => {
|
||||||
|
const response = await client.post<{ message: string }>(
|
||||||
|
`/marathons/${marathonId}/participants/${userId}/exiled-games/${gameId}/restore`
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,4 +106,31 @@ export const shopApi = {
|
|||||||
})
|
})
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// === Админские функции ===
|
||||||
|
|
||||||
|
// Получить инвентарь пользователя (админ)
|
||||||
|
adminGetUserInventory: async (userId: number, itemType?: ShopItemType): Promise<InventoryItem[]> => {
|
||||||
|
const params = itemType ? { item_type: itemType } : {}
|
||||||
|
const response = await client.get<InventoryItem[]>(`/shop/admin/users/${userId}/inventory`, { params })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Выдать предмет пользователю (админ)
|
||||||
|
adminGrantItem: async (userId: number, itemId: number, quantity: number, reason: string): Promise<{ message: string }> => {
|
||||||
|
const response = await client.post<{ message: string }>(`/shop/admin/users/${userId}/items/grant`, {
|
||||||
|
item_id: itemId,
|
||||||
|
quantity,
|
||||||
|
reason,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Удалить предмет из инвентаря пользователя (админ)
|
||||||
|
adminRemoveItem: async (userId: number, inventoryId: number, quantity: number = 1): Promise<{ message: string }> => {
|
||||||
|
const response = await client.delete<{ message: string }>(`/shop/admin/users/${userId}/inventory/${inventoryId}`, {
|
||||||
|
params: { quantity },
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
import { marathonsApi } from '@/api'
|
import { marathonsApi } from '@/api'
|
||||||
import type { LeaderboardEntry, ShopItemPublic, User } from '@/types'
|
import type { LeaderboardEntry, ShopItemPublic, User, Marathon } from '@/types'
|
||||||
import { GlassCard, UserAvatar } from '@/components/ui'
|
import { GlassCard, UserAvatar, NeonButton } from '@/components/ui'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { Trophy, Flame, ArrowLeft, Loader2, Crown, Medal, Award, Star, Zap, Target } from 'lucide-react'
|
import { useToast } from '@/store/toast'
|
||||||
|
import { Trophy, Flame, ArrowLeft, Loader2, Crown, Medal, Award, Star, Zap, Target, SkipForward, X, Ban } from 'lucide-react'
|
||||||
|
|
||||||
// Helper to get name color styles and animation class
|
// Helper to get name color styles and animation class
|
||||||
function getNameColorData(nameColor: ShopItemPublic | null | undefined): { styles: React.CSSProperties; className: string } {
|
function getNameColorData(nameColor: ShopItemPublic | null | undefined): { styles: React.CSSProperties; className: string } {
|
||||||
@@ -80,25 +81,67 @@ function StyledNickname({ user, className = '' }: { user: User; className?: stri
|
|||||||
export function LeaderboardPage() {
|
export function LeaderboardPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const user = useAuthStore((state) => state.user)
|
const user = useAuthStore((state) => state.user)
|
||||||
|
const toast = useToast()
|
||||||
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([])
|
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([])
|
||||||
|
const [marathon, setMarathon] = useState<Marathon | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
|
// Skip modal state
|
||||||
|
const [skipModalUser, setSkipModalUser] = useState<User | null>(null)
|
||||||
|
const [skipExile, setSkipExile] = useState(false)
|
||||||
|
const [skipReason, setSkipReason] = useState('')
|
||||||
|
const [isSkipping, setIsSkipping] = useState(false)
|
||||||
|
|
||||||
|
const isOrganizer = marathon?.my_participation?.role === 'organizer' || user?.role === 'admin'
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadLeaderboard()
|
loadData()
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
const loadLeaderboard = async () => {
|
const loadData = async () => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
try {
|
try {
|
||||||
const data = await marathonsApi.getLeaderboard(parseInt(id))
|
const [leaderboardData, marathonData] = await Promise.all([
|
||||||
setLeaderboard(data)
|
marathonsApi.getLeaderboard(parseInt(id)),
|
||||||
|
marathonsApi.get(parseInt(id)),
|
||||||
|
])
|
||||||
|
setLeaderboard(leaderboardData)
|
||||||
|
setMarathon(marathonData)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load leaderboard:', error)
|
console.error('Failed to load data:', error)
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSkip = async () => {
|
||||||
|
if (!skipModalUser || !id) return
|
||||||
|
|
||||||
|
setIsSkipping(true)
|
||||||
|
try {
|
||||||
|
await marathonsApi.skipParticipantAssignment(
|
||||||
|
parseInt(id),
|
||||||
|
skipModalUser.id,
|
||||||
|
skipExile,
|
||||||
|
skipReason || undefined
|
||||||
|
)
|
||||||
|
toast.success(
|
||||||
|
skipExile
|
||||||
|
? `Задание ${skipModalUser.nickname} пропущено, игра изгнана`
|
||||||
|
: `Задание ${skipModalUser.nickname} пропущено`
|
||||||
|
)
|
||||||
|
setSkipModalUser(null)
|
||||||
|
setSkipExile(false)
|
||||||
|
setSkipReason('')
|
||||||
|
loadData()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
toast.error(error.response?.data?.detail || 'Не удалось пропустить задание')
|
||||||
|
} finally {
|
||||||
|
setIsSkipping(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getRankConfig = (rank: number) => {
|
const getRankConfig = (rank: number) => {
|
||||||
switch (rank) {
|
switch (rank) {
|
||||||
case 1:
|
case 1:
|
||||||
@@ -366,6 +409,20 @@ export function LeaderboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Skip button for organizers */}
|
||||||
|
{isOrganizer && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSkipModalUser(entry.user)
|
||||||
|
}}
|
||||||
|
className="relative p-2 text-orange-400 hover:bg-orange-500/20 rounded-lg transition-colors"
|
||||||
|
title="Скипнуть задание"
|
||||||
|
>
|
||||||
|
<SkipForward className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Points */}
|
{/* Points */}
|
||||||
<div className="relative text-right">
|
<div className="relative text-right">
|
||||||
<div className={`text-xl font-bold ${isCurrentUser ? 'text-neon-400' : 'text-white'}`}>
|
<div className={`text-xl font-bold ${isCurrentUser ? 'text-neon-400' : 'text-white'}`}>
|
||||||
@@ -380,6 +437,104 @@ export function LeaderboardPage() {
|
|||||||
</GlassCard>
|
</GlassCard>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Skip Modal */}
|
||||||
|
{skipModalUser && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
|
||||||
|
<div className="glass rounded-2xl p-6 max-w-md w-full mx-4 border border-dark-600 shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
|
<SkipForward className="w-5 h-5 text-orange-400" />
|
||||||
|
Скипнуть задание {skipModalUser.nickname}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSkipModalUser(null)
|
||||||
|
setSkipExile(false)
|
||||||
|
setSkipReason('')
|
||||||
|
}}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-white rounded-lg hover:bg-dark-600/50 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skip type */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-3">
|
||||||
|
Тип скипа
|
||||||
|
</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-xl bg-dark-700/50 border border-dark-600 cursor-pointer hover:border-orange-500/30 transition-colors">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="skipType"
|
||||||
|
checked={!skipExile}
|
||||||
|
onChange={() => setSkipExile(false)}
|
||||||
|
className="w-4 h-4 text-orange-500 bg-dark-700 border-dark-500 focus:ring-orange-500/50"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="text-white font-medium">Обычный скип</div>
|
||||||
|
<div className="text-sm text-gray-400">Задание пропускается, игра может выпасть снова</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-xl bg-dark-700/50 border border-dark-600 cursor-pointer hover:border-red-500/30 transition-colors">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="skipType"
|
||||||
|
checked={skipExile}
|
||||||
|
onChange={() => setSkipExile(true)}
|
||||||
|
className="w-4 h-4 text-red-500 bg-dark-700 border-dark-500 focus:ring-red-500/50"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="text-white font-medium flex items-center gap-2">
|
||||||
|
Скип с изгнанием
|
||||||
|
<Ban className="w-4 h-4 text-red-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">Игра навсегда удаляется из пула участника</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reason */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Причина (опционально)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={skipReason}
|
||||||
|
onChange={(e) => setSkipReason(e.target.value)}
|
||||||
|
placeholder="Причина скипа..."
|
||||||
|
rows={2}
|
||||||
|
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-orange-500/50 transition-colors resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<NeonButton
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setSkipModalUser(null)
|
||||||
|
setSkipExile(false)
|
||||||
|
setSkipReason('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</NeonButton>
|
||||||
|
<NeonButton
|
||||||
|
color={skipExile ? 'pink' : 'neon'}
|
||||||
|
onClick={handleSkip}
|
||||||
|
disabled={isSkipping}
|
||||||
|
isLoading={isSkipping}
|
||||||
|
icon={<SkipForward className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
{skipExile ? 'Скипнуть и изгнать' : 'Скипнуть'}
|
||||||
|
</NeonButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -494,6 +494,35 @@ export function PlayPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleUseSkipExile = async () => {
|
||||||
|
if (!currentAssignment || !id) return
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: 'Скип с изгнанием?',
|
||||||
|
message: 'Задание будет пропущено без штрафа, а игра навсегда удалена из вашего пула.',
|
||||||
|
confirmText: 'Использовать',
|
||||||
|
cancelText: 'Отмена',
|
||||||
|
variant: 'warning',
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
setIsUsingConsumable('skip_exile')
|
||||||
|
try {
|
||||||
|
await shopApi.useConsumable({
|
||||||
|
item_code: 'skip_exile',
|
||||||
|
marathon_id: parseInt(id),
|
||||||
|
assignment_id: currentAssignment.id,
|
||||||
|
})
|
||||||
|
toast.success('Задание пропущено, игра изгнана из пула!')
|
||||||
|
await loadData()
|
||||||
|
useShopStore.getState().loadBalance()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
toast.error(error.response?.data?.detail || 'Не удалось использовать Skip с изгнанием')
|
||||||
|
} finally {
|
||||||
|
setIsUsingConsumable(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleUseBoost = async () => {
|
const handleUseBoost = async () => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
setIsUsingConsumable('boost')
|
setIsUsingConsumable('boost')
|
||||||
@@ -826,6 +855,28 @@ export function PlayPage() {
|
|||||||
</NeonButton>
|
</NeonButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Skip with Exile */}
|
||||||
|
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<XCircle className="w-4 h-4 text-red-400" />
|
||||||
|
<span className="text-white font-medium">Skip + Изгнание</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-400 text-sm">{consumablesStatus.skip_exiles_available} шт.</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-500 text-xs mb-2">Скип + убрать игру из пула</p>
|
||||||
|
<NeonButton
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleUseSkipExile}
|
||||||
|
disabled={consumablesStatus.skip_exiles_available === 0 || !currentAssignment || isUsingConsumable !== null}
|
||||||
|
isLoading={isUsingConsumable === 'skip_exile'}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Использовать
|
||||||
|
</NeonButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Boost */}
|
{/* Boost */}
|
||||||
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
|
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
@@ -1003,7 +1054,14 @@ export function PlayPage() {
|
|||||||
<p className="text-center text-gray-500 py-8">Нет доступных заданий для копирования</p>
|
<p className="text-center text-gray-500 py-8">Нет доступных заданий для копирования</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{copycatCandidates.map((candidate) => (
|
{copycatCandidates.map((candidate) => {
|
||||||
|
const displayTitle = candidate.is_playthrough
|
||||||
|
? `Прохождение: ${candidate.game_title}`
|
||||||
|
: candidate.challenge_title || ''
|
||||||
|
const displayDetails = candidate.is_playthrough
|
||||||
|
? `${candidate.playthrough_points || 0} очков`
|
||||||
|
: `${candidate.game_title} • ${candidate.challenge_points} очков • ${candidate.challenge_difficulty}`
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={candidate.participant_id}
|
key={candidate.participant_id}
|
||||||
onClick={() => handleUseCopycat(candidate.participant_id)}
|
onClick={() => handleUseCopycat(candidate.participant_id)}
|
||||||
@@ -1011,12 +1069,13 @@ export function PlayPage() {
|
|||||||
className="w-full p-3 bg-dark-700/50 hover:bg-dark-700 rounded-xl text-left transition-colors border border-dark-600 hover:border-cyan-500/30 disabled:opacity-50"
|
className="w-full p-3 bg-dark-700/50 hover:bg-dark-700 rounded-xl text-left transition-colors border border-dark-600 hover:border-cyan-500/30 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<p className="text-white font-medium">{candidate.user.nickname}</p>
|
<p className="text-white font-medium">{candidate.user.nickname}</p>
|
||||||
<p className="text-cyan-400 text-sm">{candidate.challenge_title}</p>
|
<p className="text-cyan-400 text-sm">{displayTitle}</p>
|
||||||
<p className="text-gray-500 text-xs">
|
<p className="text-gray-500 text-xs">
|
||||||
{candidate.game_title} • {candidate.challenge_points} очков • {candidate.challenge_difficulty}
|
{displayDetails}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
@@ -1781,7 +1840,14 @@ export function PlayPage() {
|
|||||||
Входящие запросы ({swapRequests.incoming.length})
|
Входящие запросы ({swapRequests.incoming.length})
|
||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{swapRequests.incoming.map((request) => (
|
{swapRequests.incoming.map((request) => {
|
||||||
|
const challengeTitle = request.from_challenge.is_playthrough
|
||||||
|
? `Прохождение: ${request.from_challenge.game_title}`
|
||||||
|
: request.from_challenge.title || ''
|
||||||
|
const challengeDetails = request.from_challenge.is_playthrough
|
||||||
|
? `${request.from_challenge.playthrough_points || 0} очков`
|
||||||
|
: `${request.from_challenge.game_title} • ${request.from_challenge.points} очков`
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={request.id}
|
key={request.id}
|
||||||
className="p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-xl"
|
className="p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-xl"
|
||||||
@@ -1792,10 +1858,10 @@ export function PlayPage() {
|
|||||||
{request.from_user.nickname} предлагает обмен
|
{request.from_user.nickname} предлагает обмен
|
||||||
</p>
|
</p>
|
||||||
<p className="text-yellow-400 text-sm mt-1">
|
<p className="text-yellow-400 text-sm mt-1">
|
||||||
Вы получите: <span className="font-medium">{request.from_challenge.title}</span>
|
Вы получите: <span className="font-medium">{challengeTitle}</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-400 text-xs">
|
<p className="text-gray-400 text-xs">
|
||||||
{request.from_challenge.game_title} • {request.from_challenge.points} очков
|
{challengeDetails}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@@ -1822,7 +1888,8 @@ export function PlayPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1835,7 +1902,11 @@ export function PlayPage() {
|
|||||||
Отправленные запросы ({swapRequests.outgoing.length})
|
Отправленные запросы ({swapRequests.outgoing.length})
|
||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{swapRequests.outgoing.map((request) => (
|
{swapRequests.outgoing.map((request) => {
|
||||||
|
const challengeTitle = request.to_challenge.is_playthrough
|
||||||
|
? `Прохождение: ${request.to_challenge.game_title}`
|
||||||
|
: request.to_challenge.title || ''
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={request.id}
|
key={request.id}
|
||||||
className="p-4 bg-accent-500/10 border border-accent-500/30 rounded-xl"
|
className="p-4 bg-accent-500/10 border border-accent-500/30 rounded-xl"
|
||||||
@@ -1846,7 +1917,7 @@ export function PlayPage() {
|
|||||||
Запрос к {request.to_user.nickname}
|
Запрос к {request.to_user.nickname}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-accent-400 text-sm mt-1">
|
<p className="text-accent-400 text-sm mt-1">
|
||||||
Вы получите: <span className="font-medium">{request.to_challenge.title}</span>
|
Вы получите: <span className="font-medium">{challengeTitle}</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-500 text-xs mt-1">
|
<p className="text-gray-500 text-xs mt-1">
|
||||||
Ожидание подтверждения...
|
Ожидание подтверждения...
|
||||||
@@ -1864,7 +1935,8 @@ export function PlayPage() {
|
|||||||
</NeonButton>
|
</NeonButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1892,7 +1964,14 @@ export function PlayPage() {
|
|||||||
!swapRequests.outgoing.some(r => r.to_user.id === c.user.id) &&
|
!swapRequests.outgoing.some(r => r.to_user.id === c.user.id) &&
|
||||||
!swapRequests.incoming.some(r => r.from_user.id === c.user.id)
|
!swapRequests.incoming.some(r => r.from_user.id === c.user.id)
|
||||||
)
|
)
|
||||||
.map((candidate) => (
|
.map((candidate) => {
|
||||||
|
const displayTitle = candidate.is_playthrough
|
||||||
|
? `Прохождение: ${candidate.game_title}`
|
||||||
|
: candidate.challenge_title || ''
|
||||||
|
const displayDetails = candidate.is_playthrough
|
||||||
|
? `${candidate.playthrough_points || 0} очков`
|
||||||
|
: `${candidate.game_title} • ${candidate.challenge_points} очков • ${candidate.challenge_difficulty}`
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={candidate.participant_id}
|
key={candidate.participant_id}
|
||||||
className="p-4 bg-dark-700/50 rounded-xl border border-dark-600"
|
className="p-4 bg-dark-700/50 rounded-xl border border-dark-600"
|
||||||
@@ -1903,10 +1982,10 @@ export function PlayPage() {
|
|||||||
{candidate.user.nickname}
|
{candidate.user.nickname}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-neon-400 text-sm font-medium truncate">
|
<p className="text-neon-400 text-sm font-medium truncate">
|
||||||
{candidate.challenge_title}
|
{displayTitle}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-400 text-xs mt-1">
|
<p className="text-gray-400 text-xs mt-1">
|
||||||
{candidate.game_title} • {candidate.challenge_points} очков • {candidate.challenge_difficulty}
|
{displayDetails}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NeonButton
|
<NeonButton
|
||||||
@@ -1915,7 +1994,7 @@ export function PlayPage() {
|
|||||||
onClick={() => handleSendSwapRequest(
|
onClick={() => handleSendSwapRequest(
|
||||||
candidate.participant_id,
|
candidate.participant_id,
|
||||||
candidate.user.nickname,
|
candidate.user.nickname,
|
||||||
candidate.challenge_title
|
displayTitle
|
||||||
)}
|
)}
|
||||||
isLoading={sendingRequestTo === candidate.participant_id}
|
isLoading={sendingRequestTo === candidate.participant_id}
|
||||||
disabled={sendingRequestTo !== null}
|
disabled={sendingRequestTo !== null}
|
||||||
@@ -1925,7 +2004,8 @@ export function PlayPage() {
|
|||||||
</NeonButton>
|
</NeonButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
404
frontend/src/pages/admin/AdminGrantItemPage.tsx
Normal file
404
frontend/src/pages/admin/AdminGrantItemPage.tsx
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { shopApi, adminApi } from '@/api'
|
||||||
|
import { useToast } from '@/store/toast'
|
||||||
|
import { GlassCard, NeonButton, FramePreview } from '@/components/ui'
|
||||||
|
import {
|
||||||
|
Loader2, Gift, ArrowLeft, Package,
|
||||||
|
Frame, Type, Palette, Image, Zap, SkipForward,
|
||||||
|
Minus, Plus, Shuffle, Dice5, Copy, Undo2, X, XCircle
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { ShopItem, ShopItemType, ShopItemPublic, AdminUser } from '@/types'
|
||||||
|
import { RARITY_COLORS, RARITY_NAMES } from '@/types'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
const ITEM_TYPE_ICONS: Record<ShopItemType, React.ReactNode> = {
|
||||||
|
frame: <Frame className="w-5 h-5" />,
|
||||||
|
title: <Type className="w-5 h-5" />,
|
||||||
|
name_color: <Palette className="w-5 h-5" />,
|
||||||
|
background: <Image className="w-5 h-5" />,
|
||||||
|
consumable: <Zap className="w-5 h-5" />,
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONSUMABLE_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
skip: <SkipForward className="w-8 h-8" />,
|
||||||
|
skip_exile: <XCircle className="w-8 h-8" />,
|
||||||
|
boost: <Zap className="w-8 h-8" />,
|
||||||
|
wild_card: <Shuffle className="w-8 h-8" />,
|
||||||
|
lucky_dice: <Dice5 className="w-8 h-8" />,
|
||||||
|
copycat: <Copy className="w-8 h-8" />,
|
||||||
|
undo: <Undo2 className="w-8 h-8" />,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ITEM_TYPE_LABELS: Record<ShopItemType | 'all', string> = {
|
||||||
|
all: 'Все',
|
||||||
|
consumable: 'Расходники',
|
||||||
|
frame: 'Рамки',
|
||||||
|
title: 'Титулы',
|
||||||
|
name_color: 'Цвета',
|
||||||
|
background: 'Фоны',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GrantItemCardProps {
|
||||||
|
item: ShopItem
|
||||||
|
onGrant: (item: ShopItem) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function GrantItemCard({ item, onGrant }: GrantItemCardProps) {
|
||||||
|
const rarityColors = RARITY_COLORS[item.rarity]
|
||||||
|
|
||||||
|
const getItemPreview = () => {
|
||||||
|
if (item.item_type === 'consumable') {
|
||||||
|
return CONSUMABLE_ICONS[item.code] || <Package className="w-8 h-8" />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.item_type === 'name_color') {
|
||||||
|
const data = item.asset_data as { style?: string; color?: string; gradient?: string[] } | null
|
||||||
|
|
||||||
|
if (data?.style === 'gradient' && data.gradient) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-12 h-12 rounded-full border-2 border-dark-600"
|
||||||
|
style={{ background: `linear-gradient(135deg, ${data.gradient.join(', ')})` }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data?.style === 'animated') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-12 h-12 rounded-full border-2 border-dark-600 animate-rainbow-rotate"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #9400d3, #ff0000)',
|
||||||
|
backgroundSize: '400% 400%'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const solidColor = data?.color || '#ffffff'
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-12 h-12 rounded-full border-2 border-dark-600"
|
||||||
|
style={{ backgroundColor: solidColor }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.item_type === 'background') {
|
||||||
|
const data = item.asset_data as { type?: string; color?: string; gradient?: string[] } | null
|
||||||
|
let bgStyle: React.CSSProperties = {}
|
||||||
|
|
||||||
|
if (data?.type === 'solid' && data.color) {
|
||||||
|
bgStyle = { backgroundColor: data.color }
|
||||||
|
} else if (data?.type === 'gradient' && data.gradient) {
|
||||||
|
bgStyle = { background: `linear-gradient(135deg, ${data.gradient.join(', ')})` }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-16 h-12 rounded-lg border-2 border-dark-600"
|
||||||
|
style={bgStyle}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.item_type === 'frame') {
|
||||||
|
const frameItem: ShopItemPublic = {
|
||||||
|
id: item.id,
|
||||||
|
code: item.code,
|
||||||
|
name: item.name,
|
||||||
|
item_type: item.item_type,
|
||||||
|
rarity: item.rarity,
|
||||||
|
asset_data: item.asset_data,
|
||||||
|
}
|
||||||
|
return <FramePreview frame={frameItem} size="lg" />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.item_type === 'title' && item.asset_data?.text) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="text-lg font-bold"
|
||||||
|
style={{ color: (item.asset_data.color as string) || '#ffffff' }}
|
||||||
|
>
|
||||||
|
{item.asset_data.text as string}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ITEM_TYPE_ICONS[item.item_type]
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlassCard
|
||||||
|
className={clsx(
|
||||||
|
'p-4 border transition-all duration-300 hover:scale-[1.02]',
|
||||||
|
rarityColors.border
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Rarity badge */}
|
||||||
|
<div className={clsx('text-xs font-medium mb-2', rarityColors.text)}>
|
||||||
|
{RARITY_NAMES[item.rarity]}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Item preview */}
|
||||||
|
<div className="flex justify-center items-center h-20 mb-3">
|
||||||
|
{getItemPreview()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Item info */}
|
||||||
|
<h3 className="text-white font-semibold text-center mb-1">{item.name}</h3>
|
||||||
|
<p className="text-gray-400 text-xs text-center mb-3 line-clamp-2">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Grant button */}
|
||||||
|
<NeonButton
|
||||||
|
size="sm"
|
||||||
|
color="neon"
|
||||||
|
onClick={() => onGrant(item)}
|
||||||
|
className="w-full"
|
||||||
|
icon={<Gift className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Выдать
|
||||||
|
</NeonButton>
|
||||||
|
</GlassCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminGrantItemPage() {
|
||||||
|
const { userId } = useParams<{ userId: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const [user, setUser] = useState<AdminUser | null>(null)
|
||||||
|
const [items, setItems] = useState<ShopItem[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [activeTab, setActiveTab] = useState<ShopItemType | 'all'>('all')
|
||||||
|
|
||||||
|
// Grant modal
|
||||||
|
const [grantItem, setGrantItem] = useState<ShopItem | null>(null)
|
||||||
|
const [grantQuantity, setGrantQuantity] = useState(1)
|
||||||
|
const [grantReason, setGrantReason] = useState('')
|
||||||
|
const [isGranting, setIsGranting] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [userId])
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
if (!userId) return
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const [userData, itemsData] = await Promise.all([
|
||||||
|
adminApi.getUser(parseInt(userId)),
|
||||||
|
shopApi.getItems(),
|
||||||
|
])
|
||||||
|
setUser(userData)
|
||||||
|
setItems(itemsData)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load data:', err)
|
||||||
|
toast.error('Ошибка загрузки данных')
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGrant = async () => {
|
||||||
|
if (!grantItem || !userId || !grantReason.trim()) return
|
||||||
|
|
||||||
|
setIsGranting(true)
|
||||||
|
try {
|
||||||
|
await shopApi.adminGrantItem(
|
||||||
|
parseInt(userId),
|
||||||
|
grantItem.id,
|
||||||
|
grantQuantity,
|
||||||
|
grantReason
|
||||||
|
)
|
||||||
|
toast.success(`Выдано ${grantItem.name} x${grantQuantity} для ${user?.nickname}`)
|
||||||
|
setGrantItem(null)
|
||||||
|
setGrantQuantity(1)
|
||||||
|
setGrantReason('')
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
toast.error(error.response?.data?.detail || 'Ошибка выдачи предмета')
|
||||||
|
} finally {
|
||||||
|
setIsGranting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredItems = activeTab === 'all'
|
||||||
|
? items
|
||||||
|
: items.filter(item => item.item_type === activeTab)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-24">
|
||||||
|
<Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
|
||||||
|
<p className="text-gray-400">Загрузка...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-24">
|
||||||
|
<p className="text-gray-400">Пользователь не найден</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/admin/users')}
|
||||||
|
className="w-10 h-10 rounded-xl bg-dark-700 border border-dark-600 flex items-center justify-center text-gray-400 hover:text-white hover:border-neon-500/30 transition-all"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||||
|
<Gift className="w-7 h-7 text-green-400" />
|
||||||
|
Выдать предмет
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400">
|
||||||
|
Получатель: <span className="text-white font-medium">{user.nickname}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{(Object.keys(ITEM_TYPE_LABELS) as (ShopItemType | 'all')[]).map((type) => (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
onClick={() => setActiveTab(type)}
|
||||||
|
className={clsx(
|
||||||
|
'px-4 py-2 rounded-xl text-sm font-medium transition-all',
|
||||||
|
activeTab === type
|
||||||
|
? 'bg-neon-500/20 text-neon-400 border border-neon-500/30'
|
||||||
|
: 'bg-dark-700/50 text-gray-400 border border-dark-600 hover:text-white hover:border-dark-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{ITEM_TYPE_LABELS[type]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items grid */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
|
{filteredItems.map(item => (
|
||||||
|
<GrantItemCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onGrant={setGrantItem}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredItems.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Package className="w-16 h-16 text-gray-600 mx-auto mb-4" />
|
||||||
|
<p className="text-gray-400">Нет предметов в этой категории</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Grant Modal */}
|
||||||
|
{grantItem && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
|
||||||
|
<div className="glass rounded-2xl p-6 max-w-md w-full mx-4 border border-dark-600 shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
|
<Gift className="w-5 h-5 text-green-400" />
|
||||||
|
Выдать {grantItem.name}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setGrantItem(null)
|
||||||
|
setGrantQuantity(1)
|
||||||
|
setGrantReason('')
|
||||||
|
}}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-white rounded-lg hover:bg-dark-600/50 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-gray-400 mb-4">
|
||||||
|
Получатель: <span className="text-white">{user.nickname}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Quantity */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Количество
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setGrantQuantity(Math.max(1, grantQuantity - 1))}
|
||||||
|
disabled={grantQuantity <= 1}
|
||||||
|
className="w-10 h-10 rounded-xl bg-dark-700 hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Minus className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
value={grantQuantity}
|
||||||
|
onChange={(e) => setGrantQuantity(Math.max(1, Math.min(100, parseInt(e.target.value) || 1)))}
|
||||||
|
className="w-20 text-center bg-dark-700/50 border border-dark-600 rounded-xl px-3 py-2 text-white font-bold text-lg focus:outline-none focus:border-neon-500/50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setGrantQuantity(Math.min(100, grantQuantity + 1))}
|
||||||
|
disabled={grantQuantity >= 100}
|
||||||
|
className="w-10 h-10 rounded-xl bg-dark-700 hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reason */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Причина <span className="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={grantReason}
|
||||||
|
onChange={(e) => setGrantReason(e.target.value)}
|
||||||
|
placeholder="Причина выдачи предмета..."
|
||||||
|
rows={3}
|
||||||
|
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-neon-500/50 transition-colors resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<NeonButton
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setGrantItem(null)
|
||||||
|
setGrantQuantity(1)
|
||||||
|
setGrantReason('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</NeonButton>
|
||||||
|
<NeonButton
|
||||||
|
color="neon"
|
||||||
|
onClick={handleGrant}
|
||||||
|
disabled={!grantReason.trim() || isGranting}
|
||||||
|
isLoading={isGranting}
|
||||||
|
icon={<Gift className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Выдать x{grantQuantity}
|
||||||
|
</NeonButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { adminApi } from '@/api'
|
import { adminApi } from '@/api'
|
||||||
import type { AdminUser, UserRole } from '@/types'
|
import type { AdminUser, UserRole } from '@/types'
|
||||||
import { useToast } from '@/store/toast'
|
import { useToast } from '@/store/toast'
|
||||||
import { useConfirm } from '@/store/confirm'
|
import { useConfirm } from '@/store/confirm'
|
||||||
import { NeonButton } from '@/components/ui'
|
import { NeonButton } from '@/components/ui'
|
||||||
import { Search, Ban, UserCheck, Shield, ShieldOff, ChevronLeft, ChevronRight, Users, X, KeyRound, Bell, BellOff } from 'lucide-react'
|
import { Search, Ban, UserCheck, Shield, ShieldOff, ChevronLeft, ChevronRight, Users, X, KeyRound, Bell, BellOff, Gift } from 'lucide-react'
|
||||||
|
|
||||||
export function AdminUsersPage() {
|
export function AdminUsersPage() {
|
||||||
const [users, setUsers] = useState<AdminUser[]>([])
|
const [users, setUsers] = useState<AdminUser[]>([])
|
||||||
@@ -319,6 +320,14 @@ export function AdminUsersPage() {
|
|||||||
>
|
>
|
||||||
<KeyRound className="w-4 h-4" />
|
<KeyRound className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to={`/admin/users/${user.id}/grant-item`}
|
||||||
|
className="p-2 text-green-400 hover:bg-green-500/20 rounded-lg transition-colors"
|
||||||
|
title="Выдать предмет"
|
||||||
|
>
|
||||||
|
<Gift className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -512,6 +521,7 @@ export function AdminUsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ export { AdminLogsPage } from './AdminLogsPage'
|
|||||||
export { AdminBroadcastPage } from './AdminBroadcastPage'
|
export { AdminBroadcastPage } from './AdminBroadcastPage'
|
||||||
export { AdminContentPage } from './AdminContentPage'
|
export { AdminContentPage } from './AdminContentPage'
|
||||||
export { AdminPromoCodesPage } from './AdminPromoCodesPage'
|
export { AdminPromoCodesPage } from './AdminPromoCodesPage'
|
||||||
|
export { AdminGrantItemPage } from './AdminGrantItemPage'
|
||||||
|
|||||||
@@ -181,6 +181,15 @@ export interface GameShort {
|
|||||||
game_type?: GameType
|
game_type?: GameType
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExiledGame {
|
||||||
|
id: number
|
||||||
|
game_id: number
|
||||||
|
game_title: string
|
||||||
|
exiled_at: string
|
||||||
|
exiled_by: 'user' | 'organizer' | 'admin'
|
||||||
|
reason: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface AvailableGamesCount {
|
export interface AvailableGamesCount {
|
||||||
available: number
|
available: number
|
||||||
total: number
|
total: number
|
||||||
@@ -312,10 +321,16 @@ export interface DroppedAssignment {
|
|||||||
export interface SwapCandidate {
|
export interface SwapCandidate {
|
||||||
participant_id: number
|
participant_id: number
|
||||||
user: User
|
user: User
|
||||||
challenge_title: string
|
is_playthrough: boolean
|
||||||
challenge_description: string
|
// Challenge fields (used when is_playthrough=false)
|
||||||
challenge_points: number
|
challenge_title: string | null
|
||||||
challenge_difficulty: Difficulty
|
challenge_description: string | null
|
||||||
|
challenge_points: number | null
|
||||||
|
challenge_difficulty: Difficulty | null
|
||||||
|
// Playthrough fields (used when is_playthrough=true)
|
||||||
|
playthrough_description: string | null
|
||||||
|
playthrough_points: number | null
|
||||||
|
// Common field
|
||||||
game_title: string
|
game_title: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,10 +338,16 @@ export interface SwapCandidate {
|
|||||||
export type SwapRequestStatus = 'pending' | 'accepted' | 'declined' | 'cancelled'
|
export type SwapRequestStatus = 'pending' | 'accepted' | 'declined' | 'cancelled'
|
||||||
|
|
||||||
export interface SwapRequestChallengeInfo {
|
export interface SwapRequestChallengeInfo {
|
||||||
title: string
|
is_playthrough: boolean
|
||||||
description: string
|
// Challenge fields (used when is_playthrough=false)
|
||||||
points: number
|
title: string | null
|
||||||
difficulty: string
|
description: string | null
|
||||||
|
points: number | null
|
||||||
|
difficulty: string | null
|
||||||
|
// Playthrough fields (used when is_playthrough=true)
|
||||||
|
playthrough_description: string | null
|
||||||
|
playthrough_points: number | null
|
||||||
|
// Common field
|
||||||
game_title: string
|
game_title: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -719,7 +740,7 @@ export interface PasswordChangeData {
|
|||||||
|
|
||||||
export type ShopItemType = 'frame' | 'title' | 'name_color' | 'background' | 'consumable'
|
export type ShopItemType = 'frame' | 'title' | 'name_color' | 'background' | 'consumable'
|
||||||
export type ItemRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
|
export type ItemRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
|
||||||
export type ConsumableType = 'skip' | 'boost' | 'wild_card' | 'lucky_dice' | 'copycat' | 'undo'
|
export type ConsumableType = 'skip' | 'skip_exile' | 'boost' | 'wild_card' | 'lucky_dice' | 'copycat' | 'undo'
|
||||||
|
|
||||||
export interface ShopItemPublic {
|
export interface ShopItemPublic {
|
||||||
id: number
|
id: number
|
||||||
@@ -806,6 +827,7 @@ export interface CoinsBalance {
|
|||||||
|
|
||||||
export interface ConsumablesStatus {
|
export interface ConsumablesStatus {
|
||||||
skips_available: number
|
skips_available: number
|
||||||
|
skip_exiles_available: number
|
||||||
skips_used: number
|
skips_used: number
|
||||||
skips_remaining: number | null
|
skips_remaining: number | null
|
||||||
boosts_available: number
|
boosts_available: number
|
||||||
|
|||||||
Reference in New Issue
Block a user