mirror of
https://github.com/myronblair/kino-app
synced 2026-07-27 21:18:43 -05:00
Wire Requests approval into Radarr/Sonarr with live download status
Admin can now Approve a pending request as Movie or TV, which looks up the title via Radarr/Sonarr's own lookup API, adds the best match with search-on-add enabled, and tracks the resulting radarr_id/sonarr_id on the request. A live status line (searching/queued/downloading/downloaded, polled every 15s) shows next to each approved request.
This commit is contained in:
+10
-1
@@ -191,6 +191,10 @@ class RequestUpdate(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
class RequestApprove(BaseModel):
|
||||||
|
content_type: str # "movie" | "tv"
|
||||||
|
|
||||||
|
|
||||||
class MovieRequest(BaseModel):
|
class MovieRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="ignore")
|
model_config = ConfigDict(extra="ignore")
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
@@ -199,7 +203,12 @@ class MovieRequest(BaseModel):
|
|||||||
title: str
|
title: str
|
||||||
year: Optional[int] = None
|
year: Optional[int] = None
|
||||||
notes: str = ""
|
notes: str = ""
|
||||||
status: str = "pending"
|
status: str = "pending" # pending | approved | fulfilled | rejected
|
||||||
|
content_type: Optional[str] = None # movie | tv, set once approved
|
||||||
|
radarr_id: Optional[int] = None
|
||||||
|
sonarr_id: Optional[int] = None
|
||||||
|
matched_title: Optional[str] = None
|
||||||
|
matched_year: Optional[int] = None
|
||||||
created_at: str = Field(default_factory=_now_iso)
|
created_at: str = Field(default_factory=_now_iso)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,62 @@ def list_movies(base_url: str, api_key: str) -> List[Dict]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def search_movie(base_url: str, api_key: str, term: str) -> List[Dict]:
|
||||||
|
"""Radarr's own title lookup (proxies TMDB) — used to resolve a free-text request to a real movie."""
|
||||||
|
base_url = base_url.rstrip("/")
|
||||||
|
r = requests.get(f"{base_url}/api/v3/movie/lookup", params={"term": term}, headers=_h(api_key), timeout=20)
|
||||||
|
r.raise_for_status()
|
||||||
|
out = []
|
||||||
|
for m in r.json() or []:
|
||||||
|
poster = ""
|
||||||
|
for img in m.get("images", []) or []:
|
||||||
|
if img.get("coverType") == "poster":
|
||||||
|
poster = img.get("remoteUrl") or img.get("url") or ""
|
||||||
|
break
|
||||||
|
out.append({
|
||||||
|
"tmdb_id": m.get("tmdbId"), "title": m.get("title") or "",
|
||||||
|
"year": m.get("year"), "overview": m.get("overview") or "", "poster_url": poster,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def add_movie(base_url: str, api_key: str, tmdb_id: int, title: str, year: Optional[int]) -> Dict:
|
||||||
|
"""Adds a movie by tmdb_id with monitoring + an immediate search enabled, using the first
|
||||||
|
configured quality profile and root folder — same defaults Radarr's own UI would pre-fill."""
|
||||||
|
base_url = base_url.rstrip("/")
|
||||||
|
h = _h(api_key)
|
||||||
|
profiles = requests.get(f"{base_url}/api/v3/qualityprofile", headers=h, timeout=15).json()
|
||||||
|
roots = requests.get(f"{base_url}/api/v3/rootfolder", headers=h, timeout=15).json()
|
||||||
|
if not profiles or not roots:
|
||||||
|
raise RuntimeError("Radarr has no quality profile or root folder configured")
|
||||||
|
payload = {
|
||||||
|
"title": title, "tmdbId": tmdb_id, "year": year or 0,
|
||||||
|
"qualityProfileId": profiles[0]["id"], "rootFolderPath": roots[0]["path"],
|
||||||
|
"monitored": True, "addOptions": {"searchForMovie": True},
|
||||||
|
}
|
||||||
|
r = requests.post(f"{base_url}/api/v3/movie", json=payload, headers=h, timeout=20)
|
||||||
|
r.raise_for_status()
|
||||||
|
added = r.json()
|
||||||
|
return {"radarr_id": added["id"], "title": added.get("title"), "year": added.get("year")}
|
||||||
|
|
||||||
|
|
||||||
|
def movie_status(base_url: str, api_key: str, radarr_id: int) -> Dict:
|
||||||
|
base_url = base_url.rstrip("/")
|
||||||
|
h = _h(api_key)
|
||||||
|
m = requests.get(f"{base_url}/api/v3/movie/{radarr_id}", headers=h, timeout=15).json()
|
||||||
|
if m.get("hasFile"):
|
||||||
|
return {"state": "downloaded"}
|
||||||
|
q = requests.get(f"{base_url}/api/v3/queue", params={"movieIds": radarr_id}, headers=h, timeout=15).json()
|
||||||
|
items = q.get("records") if isinstance(q, dict) else q
|
||||||
|
if items:
|
||||||
|
it = items[0]
|
||||||
|
size = it.get("size") or 0
|
||||||
|
sizeleft = it.get("sizeleft") or 0
|
||||||
|
pct = round(100 * (1 - (sizeleft / size)), 1) if size else 0
|
||||||
|
return {"state": (it.get("status") or "queued").lower(), "progress_pct": pct}
|
||||||
|
return {"state": "searching"}
|
||||||
|
|
||||||
|
|
||||||
def test_connection(base_url: str, api_key: str) -> bool:
|
def test_connection(base_url: str, api_key: str) -> bool:
|
||||||
base_url = base_url.rstrip("/")
|
base_url = base_url.rstrip("/")
|
||||||
try:
|
try:
|
||||||
|
|||||||
+66
-1
@@ -21,7 +21,7 @@ from models import (
|
|||||||
Movie, MovieCreate, MovieUpdate,
|
Movie, MovieCreate, MovieUpdate,
|
||||||
Subtitle,
|
Subtitle,
|
||||||
WatchlistItem, ProgressUpsert,
|
WatchlistItem, ProgressUpsert,
|
||||||
RequestCreate, RequestUpdate, MovieRequest,
|
RequestCreate, RequestUpdate, RequestApprove, MovieRequest,
|
||||||
AppSettings, AppSettingsPublic,
|
AppSettings, AppSettingsPublic,
|
||||||
TMDBSearchResult, RadarrMovieDTO,
|
TMDBSearchResult, RadarrMovieDTO,
|
||||||
TranscodeJob, QueuePauseToggle,
|
TranscodeJob, QueuePauseToggle,
|
||||||
@@ -919,6 +919,71 @@ async def update_request(request_id: str, payload: RequestUpdate, user: dict = D
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
@api.post("/requests/{request_id}/approve", response_model=MovieRequest)
|
||||||
|
async def approve_request(request_id: str, payload: RequestApprove, user: dict = Depends(require_admin)):
|
||||||
|
"""Looks up the request's title in Radarr/Sonarr, adds the best match with an immediate
|
||||||
|
search enabled, and marks the request approved. Content actually lands via the normal
|
||||||
|
Radarr/Sonarr download pipeline — this only kicks that off."""
|
||||||
|
req = await db.requests.find_one({"id": request_id}, {"_id": 0})
|
||||||
|
if not req: raise HTTPException(status_code=404, detail="Request not found")
|
||||||
|
if payload.content_type not in ("movie", "tv"):
|
||||||
|
raise HTTPException(status_code=400, detail="content_type must be movie or tv")
|
||||||
|
s = await get_settings()
|
||||||
|
|
||||||
|
if payload.content_type == "movie":
|
||||||
|
if not s.get("radarr_url") or not s.get("radarr_api_key"):
|
||||||
|
raise HTTPException(status_code=400, detail="Radarr not configured")
|
||||||
|
try:
|
||||||
|
candidates = await asyncio.to_thread(radarr_client.search_movie, s["radarr_url"], s["radarr_api_key"], req["title"])
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Radarr lookup error: {e}")
|
||||||
|
match = next((c for c in candidates if req.get("year") and c.get("year") == req["year"]), candidates[0] if candidates else None)
|
||||||
|
if not match:
|
||||||
|
raise HTTPException(status_code=404, detail="No match found in Radarr")
|
||||||
|
try:
|
||||||
|
added = await asyncio.to_thread(radarr_client.add_movie, s["radarr_url"], s["radarr_api_key"], match["tmdb_id"], match["title"], match.get("year"))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Radarr add error: {e}")
|
||||||
|
updates = {"status": "approved", "content_type": "movie", "radarr_id": added["radarr_id"],
|
||||||
|
"matched_title": added.get("title"), "matched_year": added.get("year")}
|
||||||
|
else:
|
||||||
|
if not s.get("sonarr_url") or not s.get("sonarr_api_key"):
|
||||||
|
raise HTTPException(status_code=400, detail="Sonarr not configured")
|
||||||
|
try:
|
||||||
|
candidates = await asyncio.to_thread(sonarr_client.search_series, s["sonarr_url"], s["sonarr_api_key"], req["title"])
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Sonarr lookup error: {e}")
|
||||||
|
match = next((c for c in candidates if req.get("year") and c.get("year") == req["year"]), candidates[0] if candidates else None)
|
||||||
|
if not match:
|
||||||
|
raise HTTPException(status_code=404, detail="No match found in Sonarr")
|
||||||
|
try:
|
||||||
|
added = await asyncio.to_thread(sonarr_client.add_series, s["sonarr_url"], s["sonarr_api_key"], match["tvdb_id"], match["title"], match.get("year"))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Sonarr add error: {e}")
|
||||||
|
updates = {"status": "approved", "content_type": "tv", "sonarr_id": added["sonarr_id"],
|
||||||
|
"matched_title": added.get("title"), "matched_year": added.get("year")}
|
||||||
|
|
||||||
|
res = await db.requests.find_one_and_update(
|
||||||
|
{"id": request_id}, {"$set": updates}, projection={"_id": 0}, return_document=True,
|
||||||
|
)
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
@api.get("/requests/{request_id}/status")
|
||||||
|
async def request_download_status(request_id: str, user: dict = Depends(require_admin)):
|
||||||
|
req = await db.requests.find_one({"id": request_id}, {"_id": 0})
|
||||||
|
if not req: raise HTTPException(status_code=404, detail="Request not found")
|
||||||
|
s = await get_settings()
|
||||||
|
try:
|
||||||
|
if req.get("radarr_id") and s.get("radarr_url") and s.get("radarr_api_key"):
|
||||||
|
return await asyncio.to_thread(radarr_client.movie_status, s["radarr_url"], s["radarr_api_key"], req["radarr_id"])
|
||||||
|
if req.get("sonarr_id") and s.get("sonarr_url") and s.get("sonarr_api_key"):
|
||||||
|
return await asyncio.to_thread(sonarr_client.series_status, s["sonarr_url"], s["sonarr_api_key"], req["sonarr_id"])
|
||||||
|
except Exception as e:
|
||||||
|
return {"state": "unknown", "error": str(e)}
|
||||||
|
return {"state": req.get("status", "pending")}
|
||||||
|
|
||||||
|
|
||||||
# ============ SETTINGS (admin) ============
|
# ============ SETTINGS (admin) ============
|
||||||
@api.get("/settings", response_model=AppSettingsPublic)
|
@api.get("/settings", response_model=AppSettingsPublic)
|
||||||
async def read_settings(user: dict = Depends(require_admin)):
|
async def read_settings(user: dict = Depends(require_admin)):
|
||||||
|
|||||||
@@ -14,6 +14,59 @@ def _poster(images):
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def search_series(base_url: str, api_key: str, term: str) -> List[Dict]:
|
||||||
|
"""Sonarr's own title lookup (proxies TVDB) — used to resolve a free-text request to a real series."""
|
||||||
|
r = requests.get(f"{base_url.rstrip('/')}/api/v3/series/lookup", params={"term": term}, headers=_h(api_key), timeout=20)
|
||||||
|
r.raise_for_status()
|
||||||
|
out = []
|
||||||
|
for s in r.json() or []:
|
||||||
|
out.append({
|
||||||
|
"tvdb_id": s.get("tvdbId"), "tmdb_id": s.get("tmdbId"), "title": s.get("title") or "",
|
||||||
|
"year": s.get("year"), "overview": s.get("overview") or "", "poster_url": _poster(s.get("images")),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def add_series(base_url: str, api_key: str, tvdb_id: int, title: str, year) -> Dict:
|
||||||
|
"""Adds a series by tvdb_id with monitoring + an immediate missing-episode search enabled."""
|
||||||
|
base_url = base_url.rstrip("/")
|
||||||
|
h = _h(api_key)
|
||||||
|
profiles = requests.get(f"{base_url}/api/v3/qualityprofile", headers=h, timeout=15).json()
|
||||||
|
roots = requests.get(f"{base_url}/api/v3/rootfolder", headers=h, timeout=15).json()
|
||||||
|
if not profiles or not roots:
|
||||||
|
raise RuntimeError("Sonarr has no quality profile or root folder configured")
|
||||||
|
payload = {
|
||||||
|
"title": title, "tvdbId": tvdb_id, "year": year or 0,
|
||||||
|
"qualityProfileId": profiles[0]["id"], "rootFolderPath": roots[0]["path"],
|
||||||
|
"seasonFolder": True, "monitored": True,
|
||||||
|
"addOptions": {"searchForMissingEpisodes": True, "monitor": "all"},
|
||||||
|
}
|
||||||
|
r = requests.post(f"{base_url}/api/v3/series", json=payload, headers=h, timeout=20)
|
||||||
|
r.raise_for_status()
|
||||||
|
added = r.json()
|
||||||
|
return {"sonarr_id": added["id"], "title": added.get("title"), "year": added.get("year")}
|
||||||
|
|
||||||
|
|
||||||
|
def series_status(base_url: str, api_key: str, sonarr_id: int) -> Dict:
|
||||||
|
base_url = base_url.rstrip("/")
|
||||||
|
h = _h(api_key)
|
||||||
|
s = requests.get(f"{base_url}/api/v3/series/{sonarr_id}", headers=h, timeout=15).json()
|
||||||
|
stats = s.get("statistics") or {}
|
||||||
|
if stats.get("episodeFileCount", 0) > 0 and stats.get("episodeFileCount") >= stats.get("episodeCount", 1):
|
||||||
|
return {"state": "downloaded"}
|
||||||
|
q = requests.get(f"{base_url}/api/v3/queue", params={"seriesId": sonarr_id, "includeSeries": False}, headers=h, timeout=15).json()
|
||||||
|
items = q.get("records") if isinstance(q, dict) else q
|
||||||
|
if items:
|
||||||
|
it = items[0]
|
||||||
|
size = it.get("size") or 0
|
||||||
|
sizeleft = it.get("sizeleft") or 0
|
||||||
|
pct = round(100 * (1 - (sizeleft / size)), 1) if size else 0
|
||||||
|
return {"state": (it.get("status") or "queued").lower(), "progress_pct": pct}
|
||||||
|
if stats.get("episodeFileCount", 0) > 0:
|
||||||
|
return {"state": "partially downloaded"}
|
||||||
|
return {"state": "searching"}
|
||||||
|
|
||||||
|
|
||||||
def test_connection(base_url: str, api_key: str) -> bool:
|
def test_connection(base_url: str, api_key: str) -> bool:
|
||||||
try:
|
try:
|
||||||
r = requests.get(f"{base_url.rstrip('/')}/api/v3/system/status", headers=_h(api_key), timeout=10)
|
r = requests.get(f"{base_url.rstrip('/')}/api/v3/system/status", headers=_h(api_key), timeout=10)
|
||||||
|
|||||||
@@ -3,6 +3,43 @@ import api from "../lib/api";
|
|||||||
import { useAuth } from "../lib/auth";
|
import { useAuth } from "../lib/auth";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const STATUS_LABEL = {
|
||||||
|
searching: "Searching…",
|
||||||
|
queued: "Queued",
|
||||||
|
downloading: "Downloading",
|
||||||
|
"partially downloaded": "Partially downloaded",
|
||||||
|
downloaded: "Downloaded",
|
||||||
|
completed: "Downloaded",
|
||||||
|
delay: "Waiting",
|
||||||
|
unknown: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
function LiveStatus({ requestId }) {
|
||||||
|
const [status, setStatus] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get(`/requests/${requestId}/status`);
|
||||||
|
if (!cancelled) setStatus(data);
|
||||||
|
} catch { /* transient — next poll will retry */ }
|
||||||
|
};
|
||||||
|
poll();
|
||||||
|
const t = setInterval(poll, 15000);
|
||||||
|
return () => { cancelled = true; clearInterval(t); };
|
||||||
|
}, [requestId]);
|
||||||
|
|
||||||
|
if (!status) return null;
|
||||||
|
const label = STATUS_LABEL[status.state] || status.state;
|
||||||
|
if (!label) return null;
|
||||||
|
return (
|
||||||
|
<span className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">
|
||||||
|
{label}{typeof status.progress_pct === "number" ? ` · ${status.progress_pct}%` : ""}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Requests() {
|
export default function Requests() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [mine, setMine] = useState([]);
|
const [mine, setMine] = useState([]);
|
||||||
@@ -10,6 +47,7 @@ export default function Requests() {
|
|||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [year, setYear] = useState("");
|
const [year, setYear] = useState("");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
|
const [approving, setApproving] = useState({});
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const { data } = await api.get("/requests/mine");
|
const { data } = await api.get("/requests/mine");
|
||||||
@@ -21,6 +59,19 @@ export default function Requests() {
|
|||||||
};
|
};
|
||||||
useEffect(() => { load(); }, [user?.is_admin]);
|
useEffect(() => { load(); }, [user?.is_admin]);
|
||||||
|
|
||||||
|
const approve = async (id, contentType) => {
|
||||||
|
setApproving((p) => ({ ...p, [id]: true }));
|
||||||
|
try {
|
||||||
|
const { data } = await api.post(`/requests/${id}/approve`, { content_type: contentType });
|
||||||
|
toast.success(`Searching for "${data.matched_title || data.title}"`);
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.response?.data?.detail || "Could not approve request");
|
||||||
|
} finally {
|
||||||
|
setApproving((p) => ({ ...p, [id]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const submit = async (e) => {
|
const submit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!title.trim()) return;
|
if (!title.trim()) return;
|
||||||
@@ -107,7 +158,8 @@ export default function Requests() {
|
|||||||
</div>
|
</div>
|
||||||
<span className={`text-[10px] uppercase tracking-[0.3em] px-2 py-1 ${
|
<span className={`text-[10px] uppercase tracking-[0.3em] px-2 py-1 ${
|
||||||
r.status === "fulfilled" ? "text-[#86efac]" :
|
r.status === "fulfilled" ? "text-[#86efac]" :
|
||||||
r.status === "rejected" ? "text-[#fca5a5]" : "text-[#fcd34d]"
|
r.status === "rejected" ? "text-[#fca5a5]" :
|
||||||
|
r.status === "approved" ? "text-[#93c5fd]" : "text-[#fcd34d]"
|
||||||
}`}>
|
}`}>
|
||||||
{r.status}
|
{r.status}
|
||||||
</span>
|
</span>
|
||||||
@@ -125,13 +177,26 @@ export default function Requests() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="border border-[#222]">
|
<div className="border border-[#222]">
|
||||||
{all.map((r) => (
|
{all.map((r) => (
|
||||||
<div key={r.id} className="flex items-center justify-between px-5 py-4 border-b border-[#222] last:border-b-0" data-testid={`admin-request-${r.id}`}>
|
<div key={r.id} className="flex items-center justify-between gap-4 px-5 py-4 border-b border-[#222] last:border-b-0" data-testid={`admin-request-${r.id}`}>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<p className="text-white">{r.title} {r.year ? <span className="text-[#8A8A8A]">({r.year})</span> : null}</p>
|
<p className="text-white truncate">
|
||||||
<p className="text-xs text-[#8A8A8A] mt-1">By {r.user_name || "unknown"} · {r.status}</p>
|
{r.matched_title || r.title} {(r.matched_year || r.year) ? <span className="text-[#8A8A8A]">({r.matched_year || r.year})</span> : null}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[#8A8A8A] mt-1">By {r.user_name || "unknown"} · {r.status}{r.content_type ? ` · ${r.content_type}` : ""}</p>
|
||||||
{r.notes && <p className="text-xs text-[#666] mt-1">{r.notes}</p>}
|
{r.notes && <p className="text-xs text-[#666] mt-1">{r.notes}</p>}
|
||||||
|
{r.status === "approved" && <div className="mt-1"><LiveStatus requestId={r.id} /></div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 shrink-0">
|
||||||
|
{r.status === "pending" && (
|
||||||
|
<>
|
||||||
|
<button onClick={() => approve(r.id, "movie")} disabled={approving[r.id]}
|
||||||
|
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#93c5fd] hover:text-[#93c5fd] text-[#8A8A8A] px-3 py-2 disabled:opacity-50"
|
||||||
|
data-testid={`approve-movie-${r.id}`}>Approve (Movie)</button>
|
||||||
|
<button onClick={() => approve(r.id, "tv")} disabled={approving[r.id]}
|
||||||
|
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#93c5fd] hover:text-[#93c5fd] text-[#8A8A8A] px-3 py-2 disabled:opacity-50"
|
||||||
|
data-testid={`approve-tv-${r.id}`}>Approve (TV)</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<button onClick={() => updateStatus(r.id, "fulfilled")}
|
<button onClick={() => updateStatus(r.id, "fulfilled")}
|
||||||
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#86efac] hover:text-[#86efac] text-[#8A8A8A] px-3 py-2"
|
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#86efac] hover:text-[#86efac] text-[#8A8A8A] px-3 py-2"
|
||||||
data-testid={`fulfill-${r.id}`}>Fulfilled</button>
|
data-testid={`fulfill-${r.id}`}>Fulfilled</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user