İlk commit: Stepstead backend (FastAPI)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jts 2026-07-11 11:30:24 +03:00
commit ea049b8ccd
45 changed files with 6751 additions and 0 deletions

258
routers/quests.py Normal file
View file

@ -0,0 +1,258 @@
"""
Görevler ana + yan görevler ("lore" izni; loremaster + admin).
Her görevin: görevi veren NPC, kıta, bölge (map_tags region), veriliş noktası (location/pin)
ve "nerede yapılacağı" (çok seçmeli objective location'lar) bilgisi tutulur.
Sahiplik: admin herkesinkini, diğerleri sadece kendi görevini düzenler/siler.
WAF nedeniyle sadece GET/POST.
GET /quests liste (giver + konum + bölge adları + objective'ler)
GET /quests/meta dropdown verisi (npcs, locations, regions, continents)
POST /quests yeni görev
POST /quests/{id} güncelle
POST /quests/{id}/delete sil
"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from deps import require_perm, get_user_perms, has_perm
from models import User, get_db
router = APIRouter(prefix="/quests", tags=["quests"])
TYPES = {"main", "side"}
def _author(user: User) -> str:
return (user.display_name or "").strip() or user.email
async def _assert_owner_or_admin(db: AsyncSession, user: User, row_id: int):
perms = await get_user_perms(user, db)
if has_perm(perms, "*"):
return
owner = (await db.execute(text(
"SELECT author_id FROM quests WHERE id=:id"
), {"id": row_id})).scalar_one_or_none()
if owner is None:
raise HTTPException(404, "Görev yok")
if owner != user.id:
raise HTTPException(403, "Sadece kendi görevini düzenleyebilir/silebilirsin")
# ---- Şemalar ----
class ObjectiveIn(BaseModel):
map_tag_id: int | None = None
note: str | None = Field(default=None, max_length=500)
class QuestIn(BaseModel):
title: str = Field(min_length=1, max_length=200)
quest_type: str = "side"
summary: str | None = Field(default=None, max_length=20000)
giver_npc_id: int | None = None
continent: str | None = Field(default=None, max_length=80)
region_tag_id: int | None = None
location_tag_id: int | None = None
objectives: list[ObjectiveIn] = Field(default_factory=list)
class ObjectiveOut(BaseModel):
id: int
map_tag_id: int | None
location_name: str | None
location_kind: str | None
note: str | None
class QuestOut(BaseModel):
id: int
title: str
quest_type: str
summary: str | None
giver_npc_id: int | None
giver_npc_name: str | None
continent: str | None
region_tag_id: int | None
region_name: str | None
location_tag_id: int | None
location_name: str | None
author: str | None
author_id: int | None
objectives: list[ObjectiveOut]
created_at: str
updated_at: str
# ---- Meta (dropdown) ----
class MetaNpc(BaseModel):
id: int
name: str
map_tag_id: int | None
location_name: str | None
class MetaPlace(BaseModel):
id: int
name: str
kind: str
continent: str | None
class MetaRegion(BaseModel):
id: int
name: str
continent: str | None
class MetaOut(BaseModel):
npcs: list[MetaNpc]
places: list[MetaPlace]
regions: list[MetaRegion]
continents: list[str]
@router.get("/meta", response_model=MetaOut)
async def quest_meta(user: User = Depends(require_perm("lore")), db: AsyncSession = Depends(get_db)):
npcs = (await db.execute(text(
"SELECT n.id,n.name,n.map_tag_id,t.name AS location_name "
"FROM npcs n LEFT JOIN map_tags t ON t.id=n.map_tag_id ORDER BY n.name"
))).mappings().all()
places = (await db.execute(text(
"SELECT id,name,kind,continent FROM map_tags WHERE kind IN ('region','poi') "
"ORDER BY kind DESC, continent NULLS LAST, name"
))).mappings().all()
regions = (await db.execute(text(
"SELECT id,name,continent FROM map_tags WHERE kind='region' ORDER BY continent NULLS LAST,name"
))).mappings().all()
conts = (await db.execute(text(
"SELECT name FROM map_tags WHERE kind='continent' ORDER BY name"
))).scalars().all()
return MetaOut(
npcs=[MetaNpc(**dict(r)) for r in npcs],
places=[MetaPlace(**dict(r)) for r in places],
regions=[MetaRegion(**dict(r)) for r in regions],
continents=list(conts),
)
# ---- Yardımcı: tek görevi topla ----
async def _load_quest(db: AsyncSession, quest_id: int) -> QuestOut | None:
r = (await db.execute(text(
"SELECT q.id,q.title,q.quest_type,q.summary,"
"q.giver_npc_id,n.name AS giver_npc_name,"
"q.continent,q.region_tag_id,t.name AS region_name,"
"q.location_tag_id,lt.name AS location_name,"
"q.author,q.author_id,q.created_at,q.updated_at "
"FROM quests q "
"LEFT JOIN npcs n ON n.id=q.giver_npc_id "
"LEFT JOIN map_tags t ON t.id=q.region_tag_id "
"LEFT JOIN map_tags lt ON lt.id=q.location_tag_id "
"WHERE q.id=:id"
), {"id": quest_id})).mappings().one_or_none()
if r is None:
return None
objs = (await db.execute(text(
"SELECT o.id,o.map_tag_id,mt.name AS location_name,mt.kind AS location_kind,o.note "
"FROM quest_objectives o LEFT JOIN map_tags mt ON mt.id=o.map_tag_id "
"WHERE o.quest_id=:id ORDER BY o.ordering,o.id"
), {"id": quest_id})).mappings().all()
d = dict(r)
d["created_at"] = d["created_at"].isoformat()
d["updated_at"] = d["updated_at"].isoformat()
d["objectives"] = [ObjectiveOut(**dict(o)) for o in objs]
return QuestOut(**d)
async def _validate(db: AsyncSession, q: QuestIn):
if q.quest_type not in TYPES:
raise HTTPException(400, "quest_type: main | side")
if q.giver_npc_id is not None:
ok = (await db.execute(text("SELECT 1 FROM npcs WHERE id=:id"), {"id": q.giver_npc_id})).scalar()
if not ok:
raise HTTPException(400, "Görev veren NPC yok")
if q.region_tag_id is not None:
ok = (await db.execute(text("SELECT 1 FROM map_tags WHERE id=:id AND kind='region'"),
{"id": q.region_tag_id})).scalar()
if not ok:
raise HTTPException(400, "Bölge yok")
if q.location_tag_id is not None:
ok = (await db.execute(text("SELECT 1 FROM map_tags WHERE id=:id AND kind IN ('region','poi')"),
{"id": q.location_tag_id})).scalar()
if not ok:
raise HTTPException(400, "Veriliş noktası yok (map editörü alan/pin olmalı)")
for o in q.objectives:
if o.map_tag_id is not None:
ok = (await db.execute(text("SELECT 1 FROM map_tags WHERE id=:id AND kind IN ('region','poi')"),
{"id": o.map_tag_id})).scalar()
if not ok:
raise HTTPException(400, "Görev noktası yok (map editörü alan/pin olmalı)")
async def _save_objectives(db: AsyncSession, quest_id: int, objectives: list[ObjectiveIn]):
await db.execute(text("DELETE FROM quest_objectives WHERE quest_id=:id"), {"id": quest_id})
for i, o in enumerate(objectives):
if o.map_tag_id is None and not (o.note or "").strip():
continue
await db.execute(text(
"INSERT INTO quest_objectives(quest_id,map_tag_id,note,ordering) "
"VALUES(:q,:tag,:note,:ord)"
), {"q": quest_id, "tag": o.map_tag_id, "note": (o.note or None), "ord": i})
# ---- CRUD ----
@router.get("", response_model=list[QuestOut])
async def list_quests(user: User = Depends(require_perm("lore")), db: AsyncSession = Depends(get_db)):
ids = (await db.execute(text(
"SELECT id FROM quests ORDER BY quest_type,sort_order,id"
))).scalars().all()
return [await _load_quest(db, i) for i in ids]
@router.post("", response_model=QuestOut)
async def create_quest(q: QuestIn, user: User = Depends(require_perm("lore")),
db: AsyncSession = Depends(get_db)):
await _validate(db, q)
qid = (await db.execute(text(
"INSERT INTO quests(title,quest_type,summary,giver_npc_id,continent,region_tag_id,"
"location_tag_id,author,author_id) "
"VALUES(:title,:qt,:summary,:giver,:cont,:region,:loc,:author,:aid) RETURNING id"
), {"title": q.title, "qt": q.quest_type, "summary": q.summary, "giver": q.giver_npc_id,
"cont": q.continent, "region": q.region_tag_id, "loc": q.location_tag_id,
"author": _author(user), "aid": user.id})).scalar_one()
await _save_objectives(db, qid, q.objectives)
await db.commit()
return await _load_quest(db, qid)
@router.post("/{quest_id}", response_model=QuestOut)
async def update_quest(quest_id: int, q: QuestIn, user: User = Depends(require_perm("lore")),
db: AsyncSession = Depends(get_db)):
await _assert_owner_or_admin(db, user, quest_id)
await _validate(db, q)
res = await db.execute(text(
"UPDATE quests SET title=:title,quest_type=:qt,summary=:summary,giver_npc_id=:giver,"
"continent=:cont,region_tag_id=:region,location_tag_id=:loc,updated_at=now() WHERE id=:id"
), {"id": quest_id, "title": q.title, "qt": q.quest_type, "summary": q.summary,
"giver": q.giver_npc_id, "cont": q.continent, "region": q.region_tag_id,
"loc": q.location_tag_id})
if res.rowcount == 0:
raise HTTPException(404, "Görev yok")
await _save_objectives(db, quest_id, q.objectives)
await db.commit()
return await _load_quest(db, quest_id)
@router.post("/{quest_id}/delete")
async def delete_quest(quest_id: int, user: User = Depends(require_perm("lore")),
db: AsyncSession = Depends(get_db)):
await _assert_owner_or_admin(db, user, quest_id)
res = await db.execute(text("DELETE FROM quests WHERE id=:id"), {"id": quest_id})
await db.commit()
if res.rowcount == 0:
raise HTTPException(404, "Görev yok")
return {"deleted": quest_id}