Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 32 additions & 9 deletions backend/server/api/raid_planning.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,18 @@

import datetime as dt
import logging
from typing import cast

from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field

from backend.core.log_safety import scrub
from backend.server.api.guild import _fetch_and_cache_guild, _officer_chars, _roster_rank_map
from backend.server.auth_deps import is_admin
from backend.server.cache import guild_cache
from backend.server.core.audit_log import audit_log
from backend.server.core.cache_keys import guild_roster_key
from backend.server.core.session_user import SessionUser
from backend.server.db import get_active_claims, get_display_names_for_discord_ids
from backend.server.db.availability import store as availability_db
from backend.server.db.raid_planning import VALID_ROLES
Expand All @@ -54,6 +57,8 @@ class RosterEntry(BaseModel):
cls: str | None = None
level: int | None = None
role: str | None = None # raider | raid_alt | None
rank: str | None = None # guild rank name (Manage Raiders filter)
rank_id: int | None = None


class PlacementModel(BaseModel):
Expand Down Expand Up @@ -96,11 +101,11 @@ class AvailabilityInput(BaseModel):
# ── Gates ────────────────────────────────────────────────────────────────────


def _require_session(request: Request) -> dict:
def _require_session(request: Request) -> SessionUser:
user = request.session.get("user")
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
return user
return cast("SessionUser", user)


async def _member_chars(discord_id: str, guild_name: str) -> set[str]:
Expand All @@ -113,20 +118,28 @@ async def _member_chars(discord_id: str, guild_name: str) -> set[str]:
return {n for n in approved if n in rank_map}


async def _require_member(request: Request, guild_name: str) -> tuple[dict, bool]:
async def _can_edit(user: SessionUser, guild_name: str) -> bool:
"""Officers edit; a site admin who is ALSO a member of this guild edits
too (admin alone is not enough — the planner belongs to the guild)."""
if await _officer_chars(user["id"], guild_name):
return True
return is_admin(user) and bool(await _member_chars(user["id"], guild_name))


async def _require_member(request: Request, guild_name: str) -> tuple[SessionUser, bool]:
"""401 without a session; 403 unless the viewer has an approved claim on
a character in this guild. Returns (session_user, is_officer)."""
a character in this guild. Returns (session_user, can_edit)."""
user = _require_session(request)
members = await _member_chars(user["id"], guild_name)
if not members:
raise HTTPException(status_code=403, detail="Raid planning is visible to guild members only")
officers = await _officer_chars(user["id"], guild_name)
return user, bool(officers)
editor = bool(await _officer_chars(user["id"], guild_name)) or is_admin(user)
return user, editor


async def _require_officer(request: Request, guild_name: str) -> dict:
async def _require_officer(request: Request, guild_name: str) -> SessionUser:
user = _require_session(request)
if not await _officer_chars(user["id"], guild_name):
if not await _can_edit(user, guild_name):
raise HTTPException(status_code=403, detail="Officer access required")
return user

Expand Down Expand Up @@ -179,7 +192,17 @@ async def get_planner(

members = await _guild_roster(guild_name)
roles = {r["character_name"].lower(): r["role"] for r in await planning_db.get_roles(world, guild_name)}
roster = [RosterEntry(name=m.name, cls=m.cls, level=m.level, role=roles.get(m.name.lower())) for m in members]
roster = [
RosterEntry(
name=m.name,
cls=m.cls,
level=m.level,
role=roles.get(m.name.lower()),
rank=getattr(m, "rank", None),
rank_id=getattr(m, "rank_id", None),
)
for m in members
]

placements = [PlacementModel(**p) for p in await planning_db.get_placements(world, guild_name, team_index)]

Expand Down
24 changes: 22 additions & 2 deletions frontend/src/pages/guild/raidplanner/RaidPlanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export function RaidPlanner({ guildName, teamIndex, raidDays }: {
const [selected, setSelected] = useState<string | null>(null)
const [managing, setManaging] = useState(false)
const [rosterFilter, setRosterFilter] = useState('')
const [rankFilter, setRankFilter] = useState<string>('all')

const load = useCallback(() => {
setError(null)
Expand Down Expand Up @@ -181,6 +182,14 @@ export function RaidPlanner({ guildName, teamIndex, raidDays }: {
for (const r of roled) m.set(r.name.toLowerCase(), r.cls ? byName.get(r.cls) : undefined)
return m
}, [roled, byName])
const guildRanks = useMemo(() => {
const seen = new Map<string, number>()
for (const r of data?.roster ?? []) {
if (r.rank && !seen.has(r.rank)) seen.set(r.rank, r.rank_id ?? 99)
}
return [...seen.entries()].sort((a, b) => a[1] - b[1]).map(([name]) => name)
}, [data])

const warnings = useMemo(
() => (data ? computeWarnings(data, placements, classByChar) : []),
[data, placements, classByChar],
Expand Down Expand Up @@ -416,16 +425,27 @@ export function RaidPlanner({ guildName, teamIndex, raidDays }: {
<div className="border border-border rounded-md p-2 flex flex-col gap-2">
<div className="flex items-center gap-2">
<SectionLabel variant="muted" className="mb-0">Roster designations</SectionLabel>
<select
value={rankFilter}
onChange={e => setRankFilter(e.target.value)}
className="bg-surface border border-border rounded-sm px-2 py-1 text-[0.8rem] ml-auto max-w-[150px]"
>
<option value="all">All ranks</option>
{guildRanks.map(r => (
<option key={r} value={r}>{r}</option>
))}
</select>
<input
value={rosterFilter}
onChange={e => setRosterFilter(e.target.value)}
placeholder="filter members…"
className="bg-surface border border-border rounded-sm px-2 py-1 text-[0.8rem] ml-auto w-[160px]"
placeholder="search members…"
className="bg-surface border border-border rounded-sm px-2 py-1 text-[0.8rem] w-[160px]"
/>
</div>
<div className="grid gap-x-3 gap-y-0.5 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 max-h-[320px] overflow-y-auto pr-1">
{data.roster
.filter(r => r.name.toLowerCase().includes(rosterFilter.toLowerCase()))
.filter(r => rankFilter === 'all' || r.rank === rankFilter)
.map(r => (
<div key={r.name} className="flex items-center gap-2 py-0.5">
<span className="w-1 h-4 rounded-full shrink-0" style={{ background: colourFor(r.cls) }} />
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/pages/guild/raidplanner/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export interface RosterEntry {
cls: string | null
level: number | null
role: 'raider' | 'raid_alt' | null
rank: string | null
rank_id: number | null
}

export interface Placement {
Expand Down
50 changes: 50 additions & 0 deletions tests/server/test_raid_planning.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,3 +429,53 @@ async def test_put_availability_validates_window_and_status(app):
r = await _put(app, "/api/me/availability", {"days": {ok_day: "available"}})
r = await _get(app, "/api/me/availability")
assert r.json()["days"] == {}


# ---------------------------------------------------------------------------
# API — admin-as-officer editing (admin must ALSO be a guild member)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_admin_member_can_edit_like_officer(app, monkeypatch):
"""A site admin with a claimed character in the guild edits placements."""
from backend.server import auth_deps

monkeypatch.setattr(auth_deps, "ADMIN_IDS", frozenset({"member-1"}))
await _seed_raider("Tanky")
p = _planner_patches(officer=False) # not an officer — admin path only
with p[0], p[1], p[2], p[3], p[4]:
r = await _put(
app,
f"/api/guild/{_GUILD}/raid-planning/0/placements",
{"placements": [{"character_name": "Tanky", "group_num": 1, "slot": 0, "sitout": False}]},
)
assert r.status_code == 200
g = await _get(app, f"/api/guild/{_GUILD}/raid-planning/0")
assert g.json()["is_officer"] is True # UI enables editing for the admin


@pytest.mark.asyncio
async def test_admin_who_is_not_member_still_403(app, monkeypatch):
"""Admin alone is not enough — the planner belongs to the guild."""
from backend.server import auth_deps

monkeypatch.setattr(auth_deps, "ADMIN_IDS", frozenset({"member-1"}))
await _seed_raider("Tanky")
p = _planner_patches(officer=False, member=False) # no claim in this guild
with p[0], p[1], p[2], p[3], p[4]:
r = await _put(
app,
f"/api/guild/{_GUILD}/raid-planning/0/placements",
{"placements": []},
)
assert r.status_code == 403


@pytest.mark.asyncio
async def test_planner_roster_includes_rank_fields(app):
p = _planner_patches(officer=False)
with p[0], p[1], p[2], p[3], p[4]:
r = await _get(app, f"/api/guild/{_GUILD}/raid-planning/0")
entry = r.json()["roster"][0]
assert "rank" in entry and "rank_id" in entry
Loading