From ab73f4c1c9ad05bebd7e9fadeb379a4b95745ced Mon Sep 17 00:00:00 2001 From: VortexUK Date: Sat, 11 Jul 2026 18:14:30 +0100 Subject: [PATCH] =?UTF-8?q?feat(raids):=20planner=20follow-ups=20=E2=80=94?= =?UTF-8?q?=20admin-as-officer=20editing=20+=20Manage=20Raiders=20search/r?= =?UTF-8?q?ank=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups to #160: - A site admin who is ALSO a member of the guild can edit the raid plan like an officer (roles + placements). Admin alone is not enough — the planner belongs to the guild. The GET payload's is_officer flag reflects it so the UI enables autosave. Tests cover both the admin+member happy path and the admin-non-member 403. - Manage Raiders scales to large guilds: the roster payload now carries each member's guild rank (name + id), and the panel gains a rank dropdown (ordered by rank id) alongside the renamed search box. Co-Authored-By: Claude Fable 5 --- backend/server/api/raid_planning.py | 41 +++++++++++---- .../pages/guild/raidplanner/RaidPlanner.tsx | 24 ++++++++- frontend/src/pages/guild/raidplanner/types.ts | 2 + tests/server/test_raid_planning.py | 50 +++++++++++++++++++ 4 files changed, 106 insertions(+), 11 deletions(-) diff --git a/backend/server/api/raid_planning.py b/backend/server/api/raid_planning.py index 01e35688..8c3a4814 100644 --- a/backend/server/api/raid_planning.py +++ b/backend/server/api/raid_planning.py @@ -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 @@ -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): @@ -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]: @@ -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 @@ -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)] diff --git a/frontend/src/pages/guild/raidplanner/RaidPlanner.tsx b/frontend/src/pages/guild/raidplanner/RaidPlanner.tsx index 4f1c7da2..3142f24b 100644 --- a/frontend/src/pages/guild/raidplanner/RaidPlanner.tsx +++ b/frontend/src/pages/guild/raidplanner/RaidPlanner.tsx @@ -105,6 +105,7 @@ export function RaidPlanner({ guildName, teamIndex, raidDays }: { const [selected, setSelected] = useState(null) const [managing, setManaging] = useState(false) const [rosterFilter, setRosterFilter] = useState('') + const [rankFilter, setRankFilter] = useState('all') const load = useCallback(() => { setError(null) @@ -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() + 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], @@ -416,16 +425,27 @@ export function RaidPlanner({ guildName, teamIndex, raidDays }: {
Roster designations + 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]" />
{data.roster .filter(r => r.name.toLowerCase().includes(rosterFilter.toLowerCase())) + .filter(r => rankFilter === 'all' || r.rank === rankFilter) .map(r => (
diff --git a/frontend/src/pages/guild/raidplanner/types.ts b/frontend/src/pages/guild/raidplanner/types.ts index 23c4b58b..2bbe014d 100644 --- a/frontend/src/pages/guild/raidplanner/types.ts +++ b/frontend/src/pages/guild/raidplanner/types.ts @@ -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 { diff --git a/tests/server/test_raid_planning.py b/tests/server/test_raid_planning.py index d6d17790..e3065f46 100644 --- a/tests/server/test_raid_planning.py +++ b/tests/server/test_raid_planning.py @@ -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