Add boss battles, level caps, and badge tracking
Introduces full boss battle system: data models (BossBattle, BossPokemon, BossResult), API endpoints for CRUD and per-run defeat tracking, and frontend UI including a sticky level cap bar with badge display on the run page, interleaved boss battle cards in the encounter list, and an admin panel section for managing boss battles and their pokemon teams. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
"""add boss battles
|
||||
|
||||
Revision ID: c2d3e4f5a6b7
|
||||
Revises: b1c2d3e4f5a6
|
||||
Create Date: 2026-02-08 12:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c2d3e4f5a6b7'
|
||||
down_revision: Union[str, Sequence[str], None] = 'b1c2d3e4f5a6'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'boss_battles',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('game_id', sa.Integer(), sa.ForeignKey('games.id'), nullable=False, index=True),
|
||||
sa.Column('name', sa.String(100), nullable=False),
|
||||
sa.Column('boss_type', sa.String(20), nullable=False),
|
||||
sa.Column('badge_name', sa.String(100), nullable=True),
|
||||
sa.Column('badge_image_url', sa.String(500), nullable=True),
|
||||
sa.Column('level_cap', sa.SmallInteger(), nullable=False),
|
||||
sa.Column('order', sa.SmallInteger(), nullable=False),
|
||||
sa.Column('after_route_id', sa.Integer(), sa.ForeignKey('routes.id'), nullable=True, index=True),
|
||||
sa.Column('location', sa.String(200), nullable=False),
|
||||
sa.Column('sprite_url', sa.String(500), nullable=True),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'boss_pokemon',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('boss_battle_id', sa.Integer(), sa.ForeignKey('boss_battles.id', ondelete='CASCADE'), nullable=False, index=True),
|
||||
sa.Column('pokemon_id', sa.Integer(), sa.ForeignKey('pokemon.id'), nullable=False, index=True),
|
||||
sa.Column('level', sa.SmallInteger(), nullable=False),
|
||||
sa.Column('order', sa.SmallInteger(), nullable=False),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'boss_results',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('run_id', sa.Integer(), sa.ForeignKey('nuzlocke_runs.id', ondelete='CASCADE'), nullable=False, index=True),
|
||||
sa.Column('boss_battle_id', sa.Integer(), sa.ForeignKey('boss_battles.id'), nullable=False, index=True),
|
||||
sa.Column('result', sa.String(10), nullable=False),
|
||||
sa.Column('attempts', sa.SmallInteger(), nullable=False, server_default='1'),
|
||||
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.UniqueConstraint('run_id', 'boss_battle_id', name='uq_boss_results_run_boss'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('boss_results')
|
||||
op.drop_table('boss_pokemon')
|
||||
op.drop_table('boss_battles')
|
||||
240
backend/src/app/api/bosses.py
Normal file
240
backend/src/app/api/bosses.py
Normal file
@@ -0,0 +1,240 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.database import get_session
|
||||
from app.models.boss_battle import BossBattle
|
||||
from app.models.boss_pokemon import BossPokemon
|
||||
from app.models.boss_result import BossResult
|
||||
from app.models.game import Game
|
||||
from app.models.nuzlocke_run import NuzlockeRun
|
||||
from app.schemas.boss import (
|
||||
BossBattleCreate,
|
||||
BossBattleResponse,
|
||||
BossBattleUpdate,
|
||||
BossPokemonInput,
|
||||
BossResultCreate,
|
||||
BossResultResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# --- Game-scoped (admin) endpoints ---
|
||||
|
||||
|
||||
@router.get("/games/{game_id}/bosses", response_model=list[BossBattleResponse])
|
||||
async def list_bosses(
|
||||
game_id: int, session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
game = await session.get(Game, game_id)
|
||||
if game is None:
|
||||
raise HTTPException(status_code=404, detail="Game not found")
|
||||
|
||||
result = await session.execute(
|
||||
select(BossBattle)
|
||||
.where(BossBattle.game_id == game_id)
|
||||
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
|
||||
.order_by(BossBattle.order)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/games/{game_id}/bosses", response_model=BossBattleResponse, status_code=201)
|
||||
async def create_boss(
|
||||
game_id: int,
|
||||
data: BossBattleCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
game = await session.get(Game, game_id)
|
||||
if game is None:
|
||||
raise HTTPException(status_code=404, detail="Game not found")
|
||||
|
||||
boss = BossBattle(game_id=game_id, **data.model_dump())
|
||||
session.add(boss)
|
||||
await session.commit()
|
||||
|
||||
# Re-fetch with eager loading
|
||||
result = await session.execute(
|
||||
select(BossBattle)
|
||||
.where(BossBattle.id == boss.id)
|
||||
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
@router.put("/games/{game_id}/bosses/{boss_id}", response_model=BossBattleResponse)
|
||||
async def update_boss(
|
||||
game_id: int,
|
||||
boss_id: int,
|
||||
data: BossBattleUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(
|
||||
select(BossBattle)
|
||||
.where(BossBattle.id == boss_id, BossBattle.game_id == game_id)
|
||||
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
|
||||
)
|
||||
boss = result.scalar_one_or_none()
|
||||
if boss is None:
|
||||
raise HTTPException(status_code=404, detail="Boss battle not found")
|
||||
|
||||
for field, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(boss, field, value)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(boss)
|
||||
|
||||
# Re-fetch with eager loading
|
||||
result = await session.execute(
|
||||
select(BossBattle)
|
||||
.where(BossBattle.id == boss.id)
|
||||
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
@router.delete("/games/{game_id}/bosses/{boss_id}", status_code=204)
|
||||
async def delete_boss(
|
||||
game_id: int,
|
||||
boss_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(
|
||||
select(BossBattle).where(BossBattle.id == boss_id, BossBattle.game_id == game_id)
|
||||
)
|
||||
boss = result.scalar_one_or_none()
|
||||
if boss is None:
|
||||
raise HTTPException(status_code=404, detail="Boss battle not found")
|
||||
|
||||
await session.delete(boss)
|
||||
await session.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/games/{game_id}/bosses/{boss_id}/pokemon",
|
||||
response_model=BossBattleResponse,
|
||||
)
|
||||
async def set_boss_team(
|
||||
game_id: int,
|
||||
boss_id: int,
|
||||
team: list[BossPokemonInput],
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(
|
||||
select(BossBattle)
|
||||
.where(BossBattle.id == boss_id, BossBattle.game_id == game_id)
|
||||
.options(selectinload(BossBattle.pokemon))
|
||||
)
|
||||
boss = result.scalar_one_or_none()
|
||||
if boss is None:
|
||||
raise HTTPException(status_code=404, detail="Boss battle not found")
|
||||
|
||||
# Remove existing team
|
||||
for p in boss.pokemon:
|
||||
await session.delete(p)
|
||||
|
||||
# Add new team
|
||||
for item in team:
|
||||
bp = BossPokemon(
|
||||
boss_battle_id=boss_id,
|
||||
pokemon_id=item.pokemon_id,
|
||||
level=item.level,
|
||||
order=item.order,
|
||||
)
|
||||
session.add(bp)
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Re-fetch with eager loading
|
||||
result = await session.execute(
|
||||
select(BossBattle)
|
||||
.where(BossBattle.id == boss.id)
|
||||
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
# --- Run-scoped endpoints ---
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}/boss-results", response_model=list[BossResultResponse])
|
||||
async def list_boss_results(
|
||||
run_id: int, session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
run = await session.get(NuzlockeRun, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
|
||||
result = await session.execute(
|
||||
select(BossResult)
|
||||
.where(BossResult.run_id == run_id)
|
||||
.order_by(BossResult.id)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/runs/{run_id}/boss-results", response_model=BossResultResponse, status_code=201)
|
||||
async def create_boss_result(
|
||||
run_id: int,
|
||||
data: BossResultCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
run = await session.get(NuzlockeRun, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
|
||||
boss = await session.get(BossBattle, data.boss_battle_id)
|
||||
if boss is None:
|
||||
raise HTTPException(status_code=404, detail="Boss battle not found")
|
||||
|
||||
# Check for existing result (upsert)
|
||||
existing = await session.execute(
|
||||
select(BossResult).where(
|
||||
BossResult.run_id == run_id,
|
||||
BossResult.boss_battle_id == data.boss_battle_id,
|
||||
)
|
||||
)
|
||||
result = existing.scalar_one_or_none()
|
||||
|
||||
if result:
|
||||
result.result = data.result
|
||||
result.attempts = data.attempts
|
||||
result.completed_at = datetime.now(timezone.utc) if data.result == "won" else None
|
||||
else:
|
||||
result = BossResult(
|
||||
run_id=run_id,
|
||||
boss_battle_id=data.boss_battle_id,
|
||||
result=data.result,
|
||||
attempts=data.attempts,
|
||||
completed_at=datetime.now(timezone.utc) if data.result == "won" else None,
|
||||
)
|
||||
session.add(result)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(result)
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/runs/{run_id}/boss-results/{result_id}", status_code=204)
|
||||
async def delete_boss_result(
|
||||
run_id: int,
|
||||
result_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(
|
||||
select(BossResult).where(
|
||||
BossResult.id == result_id, BossResult.run_id == run_id
|
||||
)
|
||||
)
|
||||
boss_result = result.scalar_one_or_none()
|
||||
if boss_result is None:
|
||||
raise HTTPException(status_code=404, detail="Boss result not found")
|
||||
|
||||
await session.delete(boss_result)
|
||||
await session.commit()
|
||||
return Response(status_code=204)
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api import encounters, evolutions, export, games, health, pokemon, runs, stats
|
||||
from app.api import bosses, encounters, evolutions, export, games, health, pokemon, runs, stats
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(health.router)
|
||||
@@ -10,4 +10,5 @@ api_router.include_router(evolutions.router, tags=["evolutions"])
|
||||
api_router.include_router(runs.router, prefix="/runs", tags=["runs"])
|
||||
api_router.include_router(encounters.router, tags=["encounters"])
|
||||
api_router.include_router(stats.router, prefix="/stats", tags=["stats"])
|
||||
api_router.include_router(bosses.router, tags=["bosses"])
|
||||
api_router.include_router(export.router, prefix="/export", tags=["export"])
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
from app.models.boss_battle import BossBattle
|
||||
from app.models.boss_pokemon import BossPokemon
|
||||
from app.models.boss_result import BossResult
|
||||
from app.models.encounter import Encounter
|
||||
from app.models.evolution import Evolution
|
||||
from app.models.game import Game
|
||||
@@ -7,6 +10,9 @@ from app.models.route import Route
|
||||
from app.models.route_encounter import RouteEncounter
|
||||
|
||||
__all__ = [
|
||||
"BossBattle",
|
||||
"BossPokemon",
|
||||
"BossResult",
|
||||
"Encounter",
|
||||
"Evolution",
|
||||
"Game",
|
||||
|
||||
31
backend/src/app/models/boss_battle.py
Normal file
31
backend/src/app/models/boss_battle.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from sqlalchemy import ForeignKey, SmallInteger, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class BossBattle(Base):
|
||||
__tablename__ = "boss_battles"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
game_id: Mapped[int] = mapped_column(ForeignKey("games.id"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(100))
|
||||
boss_type: Mapped[str] = mapped_column(String(20)) # gym_leader, elite_four, champion, rival, evil_team, other
|
||||
badge_name: Mapped[str | None] = mapped_column(String(100))
|
||||
badge_image_url: Mapped[str | None] = mapped_column(String(500))
|
||||
level_cap: Mapped[int] = mapped_column(SmallInteger)
|
||||
order: Mapped[int] = mapped_column(SmallInteger)
|
||||
after_route_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("routes.id"), index=True, default=None
|
||||
)
|
||||
location: Mapped[str] = mapped_column(String(200))
|
||||
sprite_url: Mapped[str | None] = mapped_column(String(500))
|
||||
|
||||
game: Mapped["Game"] = relationship(back_populates="boss_battles")
|
||||
after_route: Mapped["Route | None"] = relationship()
|
||||
pokemon: Mapped[list["BossPokemon"]] = relationship(
|
||||
back_populates="boss_battle", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<BossBattle(id={self.id}, name='{self.name}', type='{self.boss_type}')>"
|
||||
22
backend/src/app/models/boss_pokemon.py
Normal file
22
backend/src/app/models/boss_pokemon.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from sqlalchemy import ForeignKey, SmallInteger
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class BossPokemon(Base):
|
||||
__tablename__ = "boss_pokemon"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
boss_battle_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("boss_battles.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
pokemon_id: Mapped[int] = mapped_column(ForeignKey("pokemon.id"), index=True)
|
||||
level: Mapped[int] = mapped_column(SmallInteger)
|
||||
order: Mapped[int] = mapped_column(SmallInteger)
|
||||
|
||||
boss_battle: Mapped["BossBattle"] = relationship(back_populates="pokemon")
|
||||
pokemon: Mapped["Pokemon"] = relationship()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<BossPokemon(id={self.id}, boss_battle_id={self.boss_battle_id}, pokemon_id={self.pokemon_id})>"
|
||||
30
backend/src/app/models/boss_result.py
Normal file
30
backend/src/app/models/boss_result.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, SmallInteger, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class BossResult(Base):
|
||||
__tablename__ = "boss_results"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("run_id", "boss_battle_id", name="uq_boss_results_run_boss"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
run_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("nuzlocke_runs.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
boss_battle_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("boss_battles.id"), index=True
|
||||
)
|
||||
result: Mapped[str] = mapped_column(String(10)) # won, lost
|
||||
attempts: Mapped[int] = mapped_column(SmallInteger, default=1)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
run: Mapped["NuzlockeRun"] = relationship(back_populates="boss_results")
|
||||
boss_battle: Mapped["BossBattle"] = relationship()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<BossResult(id={self.id}, run_id={self.run_id}, boss_battle_id={self.boss_battle_id}, result='{self.result}')>"
|
||||
@@ -18,6 +18,7 @@ class Game(Base):
|
||||
|
||||
routes: Mapped[list["Route"]] = relationship(back_populates="game")
|
||||
runs: Mapped[list["NuzlockeRun"]] = relationship(back_populates="game")
|
||||
boss_battles: Mapped[list["BossBattle"]] = relationship(back_populates="game")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Game(id={self.id}, name='{self.name}')>"
|
||||
|
||||
@@ -22,6 +22,7 @@ class NuzlockeRun(Base):
|
||||
|
||||
game: Mapped["Game"] = relationship(back_populates="runs")
|
||||
encounters: Mapped[list["Encounter"]] = relationship(back_populates="run")
|
||||
boss_results: Mapped[list["BossResult"]] = relationship(back_populates="run")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<NuzlockeRun(id={self.id}, name='{self.name}', status='{self.status}')>"
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
from app.schemas.boss import (
|
||||
BossBattleCreate,
|
||||
BossBattleResponse,
|
||||
BossBattleUpdate,
|
||||
BossPokemonInput,
|
||||
BossPokemonResponse,
|
||||
BossResultCreate,
|
||||
BossResultResponse,
|
||||
BossResultUpdate,
|
||||
)
|
||||
from app.schemas.encounter import (
|
||||
EncounterCreate,
|
||||
EncounterDetailResponse,
|
||||
@@ -29,6 +39,14 @@ from app.schemas.pokemon import (
|
||||
from app.schemas.run import RunCreate, RunDetailResponse, RunResponse, RunUpdate
|
||||
|
||||
__all__ = [
|
||||
"BossBattleCreate",
|
||||
"BossBattleResponse",
|
||||
"BossBattleUpdate",
|
||||
"BossPokemonInput",
|
||||
"BossPokemonResponse",
|
||||
"BossResultCreate",
|
||||
"BossResultResponse",
|
||||
"BossResultUpdate",
|
||||
"BulkImportItem",
|
||||
"BulkImportResult",
|
||||
"EncounterCreate",
|
||||
|
||||
80
backend/src/app/schemas/boss.py
Normal file
80
backend/src/app/schemas/boss.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from datetime import datetime
|
||||
|
||||
from app.schemas.base import CamelModel
|
||||
from app.schemas.pokemon import PokemonResponse
|
||||
|
||||
|
||||
class BossPokemonResponse(CamelModel):
|
||||
id: int
|
||||
pokemon_id: int
|
||||
level: int
|
||||
order: int
|
||||
pokemon: PokemonResponse
|
||||
|
||||
|
||||
class BossBattleResponse(CamelModel):
|
||||
id: int
|
||||
game_id: int
|
||||
name: str
|
||||
boss_type: str
|
||||
badge_name: str | None
|
||||
badge_image_url: str | None
|
||||
level_cap: int
|
||||
order: int
|
||||
after_route_id: int | None
|
||||
location: str
|
||||
sprite_url: str | None
|
||||
pokemon: list[BossPokemonResponse] = []
|
||||
|
||||
|
||||
class BossResultResponse(CamelModel):
|
||||
id: int
|
||||
run_id: int
|
||||
boss_battle_id: int
|
||||
result: str
|
||||
attempts: int
|
||||
completed_at: datetime | None
|
||||
|
||||
|
||||
# --- Input schemas ---
|
||||
|
||||
|
||||
class BossBattleCreate(CamelModel):
|
||||
name: str
|
||||
boss_type: str
|
||||
badge_name: str | None = None
|
||||
badge_image_url: str | None = None
|
||||
level_cap: int
|
||||
order: int
|
||||
after_route_id: int | None = None
|
||||
location: str
|
||||
sprite_url: str | None = None
|
||||
|
||||
|
||||
class BossBattleUpdate(CamelModel):
|
||||
name: str | None = None
|
||||
boss_type: str | None = None
|
||||
badge_name: str | None = None
|
||||
badge_image_url: str | None = None
|
||||
level_cap: int | None = None
|
||||
order: int | None = None
|
||||
after_route_id: int | None = None
|
||||
location: str | None = None
|
||||
sprite_url: str | None = None
|
||||
|
||||
|
||||
class BossPokemonInput(CamelModel):
|
||||
pokemon_id: int
|
||||
level: int
|
||||
order: int
|
||||
|
||||
|
||||
class BossResultCreate(CamelModel):
|
||||
boss_battle_id: int
|
||||
result: str
|
||||
attempts: int = 1
|
||||
|
||||
|
||||
class BossResultUpdate(CamelModel):
|
||||
result: str | None = None
|
||||
attempts: int | None = None
|
||||
Reference in New Issue
Block a user