feat: cards come from articles, and Nextcloud is gone
Two removals the user asked for.
No deck from a PDF section. A card should be written from an article —
text a person has read, edited and published — not from whatever
happened to be on pages 40-58 of a source document. POST /flashcards/
and the generate_flashcard_deck task are gone, with the Create Cards
button on the document page. What remains: POST /articles/{id}/ai-cards,
and POST /flashcards/manual for writing a deck by hand.
And no Nextcloud. It was a per-person cloud integration for a corpus one
person loads: a settings panel asking every educator for an app
password, a second tab on the upload page, and three endpoints. The
upload page now has one way in, which is the one anybody used.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
e2919e73c2
commit
99570fb433
10 changed files with 18 additions and 649 deletions
|
|
@ -12,7 +12,7 @@ setup_logging(settings.LOG_LEVEL)
|
||||||
from app.database import engine, Base, SessionLocal
|
from app.database import engine, Base, SessionLocal
|
||||||
from app.api import errors as api_errors
|
from app.api import errors as api_errors
|
||||||
from app.api.versioning import VERSIONED_ROOT, VersionAlias
|
from app.api.versioning import VERSIONED_ROOT, VersionAlias
|
||||||
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, mynote, exams
|
from app.routers import auth, documents, quizzes, attempts, admin, tts, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, mynote, exams
|
||||||
from app.routers import access
|
from app.routers import access
|
||||||
from app.routers import feedback
|
from app.routers import feedback
|
||||||
from app.routers import folders
|
from app.routers import folders
|
||||||
|
|
@ -626,7 +626,6 @@ app.include_router(quizzes.router, prefix=f"{VERSIONED_ROOT}/quizzes", tags=["qu
|
||||||
app.include_router(attempts.router, prefix=f"{VERSIONED_ROOT}/attempts", tags=["attempts"])
|
app.include_router(attempts.router, prefix=f"{VERSIONED_ROOT}/attempts", tags=["attempts"])
|
||||||
app.include_router(admin.router, prefix=f"{VERSIONED_ROOT}/admin", tags=["admin"])
|
app.include_router(admin.router, prefix=f"{VERSIONED_ROOT}/admin", tags=["admin"])
|
||||||
app.include_router(tts.router, prefix=f"{VERSIONED_ROOT}/tts", tags=["tts"])
|
app.include_router(tts.router, prefix=f"{VERSIONED_ROOT}/tts", tags=["tts"])
|
||||||
app.include_router(nextcloud.router, prefix=f"{VERSIONED_ROOT}/nextcloud", tags=["nextcloud"])
|
|
||||||
app.include_router(categories.router, prefix=f"{VERSIONED_ROOT}/categories", tags=["categories"])
|
app.include_router(categories.router, prefix=f"{VERSIONED_ROOT}/categories", tags=["categories"])
|
||||||
app.include_router(questions.router, prefix=f"{VERSIONED_ROOT}/questions", tags=["questions"])
|
app.include_router(questions.router, prefix=f"{VERSIONED_ROOT}/questions", tags=["questions"])
|
||||||
app.include_router(question_categories.router, prefix=f"{VERSIONED_ROOT}/question-categories", tags=["question-categories"])
|
app.include_router(question_categories.router, prefix=f"{VERSIONED_ROOT}/question-categories", tags=["question-categories"])
|
||||||
|
|
|
||||||
|
|
@ -28,14 +28,6 @@ router = APIRouter()
|
||||||
|
|
||||||
# ── Schemas ──────────────────────────────────────────────────────────
|
# ── Schemas ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class FlashcardDeckCreate(BaseModel):
|
|
||||||
model_config = {"protected_namespaces": ()}
|
|
||||||
|
|
||||||
section_id: int
|
|
||||||
title: str
|
|
||||||
model_id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class FlashcardDeckUpdate(BaseModel):
|
class FlashcardDeckUpdate(BaseModel):
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
category_id: int | None = None
|
category_id: int | None = None
|
||||||
|
|
@ -133,50 +125,17 @@ def _own_deck_to_edit(deck_id: int, current_user: User, db: Session) -> Flashcar
|
||||||
|
|
||||||
# ── Deck endpoints ───────────────────────────────────────────────────
|
# ── Deck endpoints ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.post("/")
|
|
||||||
def create_flashcard_deck(
|
|
||||||
data: FlashcardDeckCreate,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user: User = Depends(require_moderator),
|
|
||||||
):
|
|
||||||
"""Start async flashcard generation from a section. Returns {job_id} immediately."""
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
section = db.query(Section).filter(Section.id == data.section_id).first()
|
|
||||||
if not section:
|
|
||||||
raise HTTPException(status_code=404, detail="Section not found")
|
|
||||||
|
|
||||||
job_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
try:
|
|
||||||
from app.tasks.quiz_tasks import generate_flashcard_deck
|
|
||||||
import redis as redis_lib
|
|
||||||
from app.config import settings
|
|
||||||
|
|
||||||
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
|
||||||
r.set(f"extraction:status:{job_id}", "pending", ex=3600)
|
|
||||||
r.lpush(f"extraction:user_jobs:{current_user.id}", job_id)
|
|
||||||
r.expire(f"extraction:user_jobs:{current_user.id}", 86400)
|
|
||||||
r.set(f"extraction:job_title:{job_id}", data.title, ex=3600)
|
|
||||||
|
|
||||||
generate_flashcard_deck.delay(
|
|
||||||
job_id=job_id,
|
|
||||||
user_id=current_user.id,
|
|
||||||
section_id=data.section_id,
|
|
||||||
title=data.title,
|
|
||||||
model_id=data.model_id,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
raise HTTPException(status_code=503, detail="Task queue unavailable")
|
|
||||||
|
|
||||||
return {"job_id": job_id, "status": "pending"}
|
|
||||||
|
|
||||||
|
|
||||||
class ManualDeckCreate(BaseModel):
|
class ManualDeckCreate(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
category_id: int | None = None
|
category_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# No deck from a PDF section. Cards are written from an article — the text a
|
||||||
|
# person has already read, edited and published — rather than from whatever
|
||||||
|
# happened to be on pages 40-58 of a source document. The article path is
|
||||||
|
# POST /articles/{id}/ai-cards; a deck by hand is POST /flashcards/manual.
|
||||||
|
|
||||||
|
|
||||||
@router.post("/manual")
|
@router.post("/manual")
|
||||||
def create_deck_manually(
|
def create_deck_manually(
|
||||||
data: ManualDeckCreate,
|
data: ManualDeckCreate,
|
||||||
|
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
"""Nextcloud WebDAV proxy — avoids browser CORS issues."""
|
|
||||||
import io
|
|
||||||
from xml.etree import ElementTree as ET
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from fastapi.responses import StreamingResponse
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from app.models.user import User
|
|
||||||
from app.utils.auth import require_moderator
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
DAV_PROPFIND = b"""<?xml version="1.0"?>
|
|
||||||
<d:propfind xmlns:d="DAV:">
|
|
||||||
<d:prop>
|
|
||||||
<d:displayname/>
|
|
||||||
<d:getcontenttype/>
|
|
||||||
<d:getcontentlength/>
|
|
||||||
<d:resourcetype/>
|
|
||||||
</d:prop>
|
|
||||||
</d:propfind>"""
|
|
||||||
|
|
||||||
|
|
||||||
class NCRequest(BaseModel):
|
|
||||||
server: str
|
|
||||||
username: str
|
|
||||||
password: str
|
|
||||||
path: str = "/"
|
|
||||||
|
|
||||||
|
|
||||||
def _dav_url(server: str, username: str, path: str) -> str:
|
|
||||||
import posixpath
|
|
||||||
base = server.rstrip("/")
|
|
||||||
# Normalize to collapse any ../ sequences before building URL
|
|
||||||
p = posixpath.normpath("/" + path).lstrip("/")
|
|
||||||
return f"{base}/remote.php/dav/files/{username}/{p}"
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_propfind(xml_bytes: bytes, base_path: str) -> list[dict]:
|
|
||||||
"""Parse WebDAV PROPFIND response into a list of file/folder dicts."""
|
|
||||||
ns = {"d": "DAV:"}
|
|
||||||
tree = ET.fromstring(xml_bytes)
|
|
||||||
items = []
|
|
||||||
for response in tree.findall("d:response", ns):
|
|
||||||
href = (response.findtext("d:href", "", ns) or "").rstrip("/")
|
|
||||||
# Skip the directory itself
|
|
||||||
props = response.find("d:propstat/d:prop", ns)
|
|
||||||
if props is None:
|
|
||||||
continue
|
|
||||||
name = props.findtext("d:displayname", "", ns) or href.split("/")[-1]
|
|
||||||
content_type = props.findtext("d:getcontenttype", "", ns) or ""
|
|
||||||
size = props.findtext("d:getcontentlength", "0", ns) or "0"
|
|
||||||
is_dir = props.find("d:resourcetype/d:collection", ns) is not None
|
|
||||||
|
|
||||||
# Build clean path from href
|
|
||||||
dav_prefix = "/remote.php/dav/files/"
|
|
||||||
if dav_prefix in href:
|
|
||||||
clean = href[href.index(dav_prefix) + len(dav_prefix):]
|
|
||||||
# Remove username prefix
|
|
||||||
parts = clean.split("/", 1)
|
|
||||||
item_path = "/" + (parts[1] if len(parts) > 1 else "")
|
|
||||||
else:
|
|
||||||
item_path = "/" + name
|
|
||||||
|
|
||||||
if is_dir:
|
|
||||||
items.append({"name": name, "path": item_path, "type": "dir", "size": 0})
|
|
||||||
elif "pdf" in content_type.lower() or name.lower().endswith(".pdf"):
|
|
||||||
items.append({"name": name, "path": item_path, "type": "pdf", "size": int(size)})
|
|
||||||
|
|
||||||
# Sort: dirs first, then PDFs
|
|
||||||
items.sort(key=lambda x: (0 if x["type"] == "dir" else 1, x["name"].lower()))
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/test")
|
|
||||||
def test_connection(req: NCRequest, _: User = Depends(require_moderator)):
|
|
||||||
"""Test Nextcloud credentials by listing the root."""
|
|
||||||
url = _dav_url(req.server, req.username, "/")
|
|
||||||
try:
|
|
||||||
resp = httpx.request(
|
|
||||||
"PROPFIND", url,
|
|
||||||
auth=(req.username, req.password),
|
|
||||||
headers={"Depth": "0", "Content-Type": "application/xml"},
|
|
||||||
content=DAV_PROPFIND,
|
|
||||||
timeout=10,
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
if resp.status_code in (200, 207):
|
|
||||||
return {"ok": True, "message": "Connected successfully"}
|
|
||||||
if resp.status_code == 401:
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
|
||||||
raise HTTPException(status_code=400, detail=f"Nextcloud returned {resp.status_code}")
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Connection failed: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/files")
|
|
||||||
def list_files(req: NCRequest, _: User = Depends(require_moderator)):
|
|
||||||
"""List PDFs and folders at the given path."""
|
|
||||||
url = _dav_url(req.server, req.username, req.path)
|
|
||||||
try:
|
|
||||||
resp = httpx.request(
|
|
||||||
"PROPFIND", url,
|
|
||||||
auth=(req.username, req.password),
|
|
||||||
headers={"Depth": "1", "Content-Type": "application/xml"},
|
|
||||||
content=DAV_PROPFIND,
|
|
||||||
timeout=15,
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
if resp.status_code == 401:
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
|
||||||
if resp.status_code not in (200, 207):
|
|
||||||
raise HTTPException(status_code=400, detail=f"Nextcloud error {resp.status_code}")
|
|
||||||
|
|
||||||
items = _parse_propfind(resp.content, req.path)
|
|
||||||
# Remove the current directory entry itself
|
|
||||||
items = [i for i in items if i["path"] != req.path]
|
|
||||||
return {"path": req.path, "items": items}
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Failed to list files: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/download")
|
|
||||||
def download_file(req: NCRequest, _: User = Depends(require_moderator)):
|
|
||||||
"""Download a file from Nextcloud and stream it back for upload."""
|
|
||||||
if not req.path.lower().endswith(".pdf"):
|
|
||||||
raise HTTPException(status_code=400, detail="Only PDF files can be imported")
|
|
||||||
url = _dav_url(req.server, req.username, req.path)
|
|
||||||
try:
|
|
||||||
resp = httpx.get(
|
|
||||||
url,
|
|
||||||
auth=(req.username, req.password),
|
|
||||||
timeout=120,
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
if resp.status_code == 401:
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
|
||||||
if resp.status_code != 200:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Download failed: {resp.status_code}")
|
|
||||||
raw_name = req.path.split("/")[-1]
|
|
||||||
# Strip characters that would break the Content-Disposition header
|
|
||||||
safe_name = raw_name.replace('"', '').replace('\\', '').replace('\n', '').replace('\r', '') or "document.pdf"
|
|
||||||
return StreamingResponse(
|
|
||||||
io.BytesIO(resp.content),
|
|
||||||
media_type="application/pdf",
|
|
||||||
headers={"Content-Disposition": f'attachment; filename="{safe_name}"',
|
|
||||||
"Content-Length": str(len(resp.content))},
|
|
||||||
)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Download failed: {e}")
|
|
||||||
|
|
@ -831,123 +831,6 @@ def regenerate_embeddings(self, job_id: str, user_id: int, stale_only: bool = Tr
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(name="generate_flashcard_deck", bind=True)
|
|
||||||
def generate_flashcard_deck(self, job_id: str, section_id: int, user_id: int,
|
|
||||||
title: str, model_id: str | None = None):
|
|
||||||
"""Generate flashcards from a document section using AI."""
|
|
||||||
r = _redis()
|
|
||||||
r.set(f"extraction:status:{job_id}", "running", ex=EXPIRE_SECONDS)
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
from app.models.section import Section
|
|
||||||
from app.models.pdf_document import PDFDocument
|
|
||||||
from app.services import vector_service
|
|
||||||
from app.services import extraction_modes
|
|
||||||
|
|
||||||
section = db.query(Section).filter(Section.id == section_id).first()
|
|
||||||
if not section:
|
|
||||||
r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
|
|
||||||
_push_step(r, job_id, "error", "Section not found")
|
|
||||||
return
|
|
||||||
document = db.query(PDFDocument).filter(PDFDocument.id == section.document_id).first()
|
|
||||||
|
|
||||||
from app.services.ai_service import get_model_for_task
|
|
||||||
ai_model_id, ai_api_key = get_model_for_task(db, "flashcard")
|
|
||||||
if model_id:
|
|
||||||
ai_model_id = model_id
|
|
||||||
|
|
||||||
total_pages = section.end_page - section.start_page + 1
|
|
||||||
_push_step(r, job_id, "start", f"Generating flashcards from {total_pages} pages…")
|
|
||||||
|
|
||||||
all_cards = []
|
|
||||||
|
|
||||||
if total_pages <= CHUNK_PAGES:
|
|
||||||
content = vector_service.get_pages_text(section.document_id, section.start_page, section.end_page)
|
|
||||||
if content:
|
|
||||||
_push_step(r, job_id, "ai", f"Generating flashcards from pages {section.start_page}–{section.end_page}…")
|
|
||||||
cards = extraction_modes.generate_flashcards(
|
|
||||||
content, f"{section.start_page}–{section.end_page}",
|
|
||||||
section.start_page, ai_model_id, ai_api_key,
|
|
||||||
)
|
|
||||||
all_cards.extend(cards)
|
|
||||||
_push_step(r, job_id, "ai", f"Generated {len(cards)} cards")
|
|
||||||
else:
|
|
||||||
n_chunks = (total_pages + CHUNK_PAGES - 1) // CHUNK_PAGES
|
|
||||||
_push_step(r, job_id, "ai", f"Large section: splitting into {n_chunks} chunks")
|
|
||||||
for chunk_idx in range(1, n_chunks + 1):
|
|
||||||
start_p = section.start_page + (chunk_idx - 1) * CHUNK_PAGES
|
|
||||||
end_p = min(start_p + CHUNK_PAGES - 1, section.end_page)
|
|
||||||
content = vector_service.get_pages_text(section.document_id, start_p, end_p)
|
|
||||||
if not content or len(content.strip()) < 100:
|
|
||||||
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: no text, skipping")
|
|
||||||
continue
|
|
||||||
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: pages {start_p}–{end_p}…")
|
|
||||||
cards = extraction_modes.generate_flashcards(
|
|
||||||
content, f"{start_p}–{end_p}", start_p, ai_model_id, ai_api_key,
|
|
||||||
)
|
|
||||||
all_cards.extend(cards)
|
|
||||||
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: {len(cards)} cards")
|
|
||||||
|
|
||||||
if not all_cards:
|
|
||||||
r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
|
|
||||||
_push_step(r, job_id, "error", "No flashcards could be generated")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Refresh DB connection for save phase
|
|
||||||
from sqlalchemy import text as _text
|
|
||||||
try:
|
|
||||||
db.execute(_text("SELECT 1"))
|
|
||||||
except Exception:
|
|
||||||
db.rollback()
|
|
||||||
db.close()
|
|
||||||
db = SessionLocal()
|
|
||||||
|
|
||||||
_push_step(r, job_id, "save", f"Saving {len(all_cards)} flashcards…")
|
|
||||||
|
|
||||||
from app.models.flashcard import FlashcardDeck, Flashcard
|
|
||||||
deck = FlashcardDeck(
|
|
||||||
title=title,
|
|
||||||
section_id=section_id,
|
|
||||||
user_id=user_id,
|
|
||||||
card_count=len(all_cards),
|
|
||||||
)
|
|
||||||
db.add(deck)
|
|
||||||
db.flush()
|
|
||||||
|
|
||||||
for c in all_cards:
|
|
||||||
card = Flashcard(
|
|
||||||
deck_id=deck.id,
|
|
||||||
front=c["front"],
|
|
||||||
back=c["back"],
|
|
||||||
page_reference=c.get("page_reference"),
|
|
||||||
)
|
|
||||||
db.add(card)
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
r.set(f"extraction:status:{job_id}", "completed", ex=EXPIRE_SECONDS)
|
|
||||||
r.set(f"extraction:deck_id:{job_id}", str(deck.id), ex=EXPIRE_SECONDS)
|
|
||||||
_push_step(r, job_id, "done", f"Created deck '{title}' with {len(all_cards)} cards")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(f"Flashcard generation failed: {e}")
|
|
||||||
r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
|
|
||||||
r.set(f"extraction:error:{job_id}", str(e)[:500], ex=EXPIRE_SECONDS)
|
|
||||||
_push_step(r, job_id, "error", f"Failed: {str(e)[:200]}")
|
|
||||||
try:
|
|
||||||
db.rollback()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
#: The three readings of a topic the reader offers, and what each one is for.
|
|
||||||
#: Written into the prompt because a model that is not told about them writes
|
|
||||||
#: one article and the other two tabs stay empty — which is what happened to
|
|
||||||
#: every generated article until now.
|
|
||||||
#: Room for the whole thing. An article with a long view, a high-yield view and
|
|
||||||
#: a clinical one runs well past four thousand tokens, and a reply that stops
|
|
||||||
#: mid-string is not a JSON document.
|
|
||||||
ARTICLE_MAX_TOKENS = 16000
|
ARTICLE_MAX_TOKENS = 16000
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2308,14 +2308,6 @@
|
||||||
"422"
|
"422"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"POST /api/v1/flashcards/": {
|
|
||||||
"body": true,
|
|
||||||
"params": [],
|
|
||||||
"responses": [
|
|
||||||
"200",
|
|
||||||
"422"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"POST /api/v1/flashcards/cards/{card_id}/review": {
|
"POST /api/v1/flashcards/cards/{card_id}/review": {
|
||||||
"body": true,
|
"body": true,
|
||||||
"params": [
|
"params": [
|
||||||
|
|
@ -2408,30 +2400,6 @@
|
||||||
"422"
|
"422"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"POST /api/v1/nextcloud/download": {
|
|
||||||
"body": true,
|
|
||||||
"params": [],
|
|
||||||
"responses": [
|
|
||||||
"200",
|
|
||||||
"422"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"POST /api/v1/nextcloud/files": {
|
|
||||||
"body": true,
|
|
||||||
"params": [],
|
|
||||||
"responses": [
|
|
||||||
"200",
|
|
||||||
"422"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"POST /api/v1/nextcloud/test": {
|
|
||||||
"body": true,
|
|
||||||
"params": [],
|
|
||||||
"responses": [
|
|
||||||
"200",
|
|
||||||
"422"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"POST /api/v1/question-categories/": {
|
"POST /api/v1/question-categories/": {
|
||||||
"body": true,
|
"body": true,
|
||||||
"params": [],
|
"params": [],
|
||||||
|
|
|
||||||
|
|
@ -182,29 +182,6 @@ export default function DocumentDetailPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const generateFlashcards = async (sectionId, sectionName) => {
|
|
||||||
setGenerating(sectionId)
|
|
||||||
setError('')
|
|
||||||
try {
|
|
||||||
const title = quizTitle || `Cards: ${sectionName}`
|
|
||||||
const res = await api.post('/flashcards/', {
|
|
||||||
section_id: sectionId,
|
|
||||||
title,
|
|
||||||
model_id: selectedModelId || null,
|
|
||||||
})
|
|
||||||
if (res.data.job_id) {
|
|
||||||
const stored = JSON.parse(localStorage.getItem('pedquiz_jobs') || '[]')
|
|
||||||
stored.unshift({ jobId: res.data.job_id, title, status: 'running', lastStep: 'Starting…', ts: Date.now() })
|
|
||||||
localStorage.setItem('pedquiz_jobs', JSON.stringify(stored.slice(0, 10)))
|
|
||||||
setActiveJob({ jobId: res.data.job_id, sectionName, type: 'flashcard' })
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.response?.data?.detail || 'Failed to start card generation. Check AI model config.')
|
|
||||||
} finally {
|
|
||||||
setGenerating(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const generateQuiz = async (sectionId, sectionName) => {
|
const generateQuiz = async (sectionId, sectionName) => {
|
||||||
setGenerating(sectionId)
|
setGenerating(sectionId)
|
||||||
setError('')
|
setError('')
|
||||||
|
|
@ -499,13 +476,6 @@ export default function DocumentDetailPage() {
|
||||||
<><span className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }}></span> Extracting...</>
|
<><span className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }}></span> Extracting...</>
|
||||||
) : 'Extract Quiz'}
|
) : 'Extract Quiz'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
className="btn btn-secondary btn-sm"
|
|
||||||
onClick={() => generateFlashcards(section.id, section.name)}
|
|
||||||
disabled={generating === section.id}
|
|
||||||
>
|
|
||||||
Create Cards
|
|
||||||
</button>
|
|
||||||
<ConfirmButton
|
<ConfirmButton
|
||||||
onConfirm={() => deleteSection(section.id)}
|
onConfirm={() => deleteSection(section.id)}
|
||||||
label="Delete section"
|
label="Delete section"
|
||||||
|
|
|
||||||
|
|
@ -129,101 +129,6 @@ function StudySection() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NextcloudSection() {
|
|
||||||
const [server, setServer] = useState('https://cloud.danvics.com')
|
|
||||||
const [username, setUsername] = useState('')
|
|
||||||
const [password, setPassword] = useState('')
|
|
||||||
const [status, setStatus] = useState(null)
|
|
||||||
const [statusMsg, setStatusMsg] = useState('')
|
|
||||||
const [loaded, setLoaded] = useState(false)
|
|
||||||
|
|
||||||
// Load from server on mount
|
|
||||||
useEffect(() => {
|
|
||||||
api.get('/auth/me/settings').then(res => {
|
|
||||||
const s = res.data
|
|
||||||
if (s.nc_server) setServer(s.nc_server)
|
|
||||||
if (s.nc_username) setUsername(s.nc_username)
|
|
||||||
if (s.nc_password) setPassword(s.nc_password)
|
|
||||||
// Also sync to localStorage for UploadPage
|
|
||||||
if (s.nc_server) localStorage.setItem('nc_server', s.nc_server)
|
|
||||||
if (s.nc_username) localStorage.setItem('nc_username', s.nc_username)
|
|
||||||
if (s.nc_password) localStorage.setItem('nc_password', s.nc_password)
|
|
||||||
}).catch(() => {}).finally(() => setLoaded(true))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
// Save to both server (cross-browser) and localStorage (for UploadPage)
|
|
||||||
localStorage.setItem('nc_server', server)
|
|
||||||
localStorage.setItem('nc_username', username)
|
|
||||||
localStorage.setItem('nc_password', password)
|
|
||||||
try {
|
|
||||||
await api.put('/auth/me/settings', { nc_server: server, nc_username: username, nc_password: password })
|
|
||||||
} catch { }
|
|
||||||
setStatus('saved')
|
|
||||||
setTimeout(() => setStatus(null), 2000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const test = async () => {
|
|
||||||
setStatus('testing')
|
|
||||||
setStatusMsg('')
|
|
||||||
try {
|
|
||||||
const res = await api.post('/nextcloud/test', { server, username, password })
|
|
||||||
setStatus('ok')
|
|
||||||
setStatusMsg(res.data.message)
|
|
||||||
} catch (err) {
|
|
||||||
setStatus('error')
|
|
||||||
setStatusMsg(err.response?.data?.detail || 'Connection failed')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const clear = async () => {
|
|
||||||
localStorage.removeItem('nc_server')
|
|
||||||
localStorage.removeItem('nc_username')
|
|
||||||
localStorage.removeItem('nc_password')
|
|
||||||
try { await api.put('/auth/me/settings', {}) } catch { }
|
|
||||||
setServer('https://cloud.danvics.com'); setUsername(''); setPassword('')
|
|
||||||
setStatus(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Section title="Nextcloud Integration">
|
|
||||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 16 }}>
|
|
||||||
Connect your Nextcloud to import PDFs directly from Upload page.
|
|
||||||
</p>
|
|
||||||
{status === 'ok' && <div className="alert alert-success">✓ {statusMsg}</div>}
|
|
||||||
{status === 'error' && <div className="alert alert-error">✗ {statusMsg}</div>}
|
|
||||||
{status === 'saved' && <div className="alert alert-success">Settings saved</div>}
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Server URL</label>
|
|
||||||
<input type="url" value={server} onChange={e => setServer(e.target.value)} placeholder="https://cloud.example.com" />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Username</label>
|
|
||||||
<input type="text" value={username} onChange={e => setUsername(e.target.value)} placeholder="your-username" autoComplete="off" />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>App Password</label>
|
|
||||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="Generate in Nextcloud → Security → App Passwords" autoComplete="new-password" />
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
|
||||||
<button className="btn btn-primary" onClick={save}>Save</button>
|
|
||||||
<button className="btn btn-secondary" onClick={test} disabled={!username || !password || status === 'testing'}>
|
|
||||||
{status === 'testing' ? 'Testing...' : 'Test Connection'}
|
|
||||||
</button>
|
|
||||||
{username && <button className="btn btn-secondary" onClick={clear}>Disconnect</button>}
|
|
||||||
</div>
|
|
||||||
<p style={{ fontSize: '0.78rem', color: 'var(--text-subtle)', marginTop: 10 }}>
|
|
||||||
Use an App Password (Nextcloud → Settings → Security), not your account password.
|
|
||||||
</p>
|
|
||||||
</Section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The places that are their own pages for good reason — a taxonomy tree, an
|
|
||||||
* editorial queue, a job log. Everything that is a *setting* now lives in this
|
|
||||||
* page's own sections rather than behind a link to a second dashboard.
|
|
||||||
*/
|
|
||||||
function ToolsSection() {
|
function ToolsSection() {
|
||||||
// Editorial is not here: it has its own entry in the section bar, and a card
|
// Editorial is not here: it has its own entry in the section bar, and a card
|
||||||
// pointing at it would be a second door to the same room.
|
// pointing at it would be a second door to the same room.
|
||||||
|
|
@ -399,7 +304,7 @@ function SitePolicySection() {
|
||||||
* Settings as a set of places, each with its own address.
|
* Settings as a set of places, each with its own address.
|
||||||
*
|
*
|
||||||
* It was one 600px column holding the account form, the theme picker, a
|
* It was one 600px column holding the account form, the theme picker, a
|
||||||
* Nextcloud integration, a document list and a grid of links — one of which
|
* A document list and a grid of links — one of which
|
||||||
* went to a second dashboard with a second row of tabs and a second visual
|
* went to a second dashboard with a second row of tabs and a second visual
|
||||||
* language. There is one place to configure the site now: the admin sections
|
* language. There is one place to configure the site now: the admin sections
|
||||||
* are rendered here, under headings that say who they are for, and the section
|
* are rendered here, under headings that say who they are for, and the section
|
||||||
|
|
@ -436,7 +341,6 @@ export default function SettingsPage() {
|
||||||
<DocumentsSection />
|
<DocumentsSection />
|
||||||
{/* One person loads the corpus. This was offered to every learner
|
{/* One person loads the corpus. This was offered to every learner
|
||||||
as though each had a cloud to connect. */}
|
as though each had a cloud to connect. */}
|
||||||
{isAdmin && <NextcloudSection />}
|
|
||||||
</>
|
</>
|
||||||
) },
|
) },
|
||||||
{ key: 'tools', group: 'Content', icon: '🛠️', label: 'Tools',
|
{ key: 'tools', group: 'Content', icon: '🛠️', label: 'Tools',
|
||||||
|
|
|
||||||
|
|
@ -185,20 +185,6 @@ export default function ToolsPage() {
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{user?.role === 'admin' && (
|
|
||||||
<section className="tools-section">
|
|
||||||
<div className="tools-section-head">
|
|
||||||
<h2>Import</h2>
|
|
||||||
</div>
|
|
||||||
<p className="tools-note">
|
|
||||||
{/* One person loads the corpus. It was in everyone's settings as
|
|
||||||
though each learner had a cloud to connect, which none of them
|
|
||||||
has and none of them needs. */}
|
|
||||||
Nextcloud is an import path for whoever loads the corpus, not a
|
|
||||||
per-learner integration. <Link to="/settings?s=library">Connect it</Link>.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,130 +2,15 @@ import { useState, useRef } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
|
|
||||||
function NextcloudBrowser({ onFile }) {
|
|
||||||
const ncServer = localStorage.getItem('nc_server') || ''
|
|
||||||
const ncUser = localStorage.getItem('nc_username') || ''
|
|
||||||
const ncPass = localStorage.getItem('nc_password') || ''
|
|
||||||
|
|
||||||
const [path, setPath] = useState('/')
|
|
||||||
const [items, setItems] = useState(null)
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [downloading, setDownloading] = useState(null)
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
|
|
||||||
const browse = async (p = path) => {
|
|
||||||
setLoading(true); setError('')
|
|
||||||
try {
|
|
||||||
const res = await api.post('/nextcloud/files', { server: ncServer, username: ncUser, password: ncPass, path: p })
|
|
||||||
setItems(res.data.items)
|
|
||||||
setPath(p)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.response?.data?.detail || 'Failed to load files')
|
|
||||||
} finally { setLoading(false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const importFile = async (item) => {
|
|
||||||
setDownloading(item.path)
|
|
||||||
try {
|
|
||||||
const res = await api.post('/nextcloud/download',
|
|
||||||
{ server: ncServer, username: ncUser, password: ncPass, path: item.path },
|
|
||||||
{ responseType: 'blob' }
|
|
||||||
)
|
|
||||||
const file = new File([res.data], item.name, { type: 'application/pdf' })
|
|
||||||
onFile(file, true)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.response?.data?.detail || 'Download failed')
|
|
||||||
} finally { setDownloading(null) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const goUp = () => {
|
|
||||||
const parts = path.replace(/\/$/, '').split('/')
|
|
||||||
parts.pop()
|
|
||||||
browse(parts.join('/') || '/')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ncUser) {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
|
|
||||||
<div style={{ fontSize: '1.5rem', marginBottom: 8 }}>☁️</div>
|
|
||||||
No Nextcloud account configured.{' '}
|
|
||||||
<Link to="/settings" style={{ color: 'var(--primary)' }}>Go to Settings</Link> to add one.
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{error && <div className="alert alert-error" style={{ marginBottom: 8 }}>{error}</div>}
|
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12, fontSize: '0.85rem' }}>
|
|
||||||
<span style={{ color: 'var(--text-muted)' }}>☁️ {ncServer}</span>
|
|
||||||
<span style={{ color: 'var(--text-muted)' }}>→</span>
|
|
||||||
<code style={{ color: 'var(--text)', fontSize: '0.82rem' }}>{path}</code>
|
|
||||||
{path !== '/' && (
|
|
||||||
<button className="btn btn-sm btn-secondary" onClick={goUp}>↑ Up</button>
|
|
||||||
)}
|
|
||||||
{items === null && (
|
|
||||||
<button className="btn btn-sm btn-primary" onClick={() => browse('/')} disabled={loading}>
|
|
||||||
{loading ? 'Loading...' : 'Browse'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{items !== null && (
|
|
||||||
<button className="btn btn-sm btn-secondary" onClick={() => browse(path)} disabled={loading}>
|
|
||||||
{loading ? '...' : '↻'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{items !== null && (
|
|
||||||
<div style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', maxHeight: 300, overflowY: 'auto' }}>
|
|
||||||
{items.length === 0 && (
|
|
||||||
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
|
|
||||||
No PDFs in this folder
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{items.map((item, i) => (
|
|
||||||
<div key={i} style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px',
|
|
||||||
borderBottom: i < items.length - 1 ? '1px solid var(--border)' : 'none',
|
|
||||||
background: 'var(--card-bg)',
|
|
||||||
}}>
|
|
||||||
<span style={{ fontSize: '1rem' }}>{item.type === 'dir' ? '📁' : '📄'}</span>
|
|
||||||
<span style={{ flex: 1, fontSize: '0.875rem', color: 'var(--text)' }}>
|
|
||||||
{item.name}
|
|
||||||
{item.type === 'pdf' && (
|
|
||||||
<span style={{ marginLeft: 8, fontSize: '0.75rem', color: 'var(--text-muted)' }}>
|
|
||||||
{(item.size / 1024 / 1024).toFixed(1)} MB
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{item.type === 'dir' ? (
|
|
||||||
<button className="btn btn-sm btn-secondary" onClick={() => browse(item.path)}>Open</button>
|
|
||||||
) : (
|
|
||||||
<button className="btn btn-sm btn-primary"
|
|
||||||
onClick={() => importFile(item)}
|
|
||||||
disabled={downloading === item.path}>
|
|
||||||
{downloading === item.path ? 'Downloading...' : 'Import'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UploadPage() {
|
export default function UploadPage() {
|
||||||
const [file, setFile] = useState(null)
|
const [file, setFile] = useState(null)
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [progress, setProgress] = useState(0)
|
const [progress, setProgress] = useState(0)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [dragging, setDragging] = useState(false)
|
const [dragging, setDragging] = useState(false)
|
||||||
const [tab, setTab] = useState('local') // 'local' | 'nextcloud'
|
|
||||||
const fileRef = useRef()
|
const fileRef = useRef()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const hasNextcloud = !!localStorage.getItem('nc_username')
|
|
||||||
|
|
||||||
const [uploadName, setUploadName] = useState('')
|
const [uploadName, setUploadName] = useState('')
|
||||||
const [stage, setStage] = useState('uploading') // 'uploading' | 'processing'
|
const [stage, setStage] = useState('uploading') // 'uploading' | 'processing'
|
||||||
|
|
@ -154,11 +39,9 @@ export default function UploadPage() {
|
||||||
} finally { clearInterval(timer); setUploading(false) }
|
} finally { clearInterval(timer); setUploading(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFile = (f, fromNextcloud = false) => {
|
const handleFile = (f) => {
|
||||||
if (f && f.type === 'application/pdf') {
|
if (f && f.type === 'application/pdf') {
|
||||||
setFile(f); setError('')
|
setFile(f); setError('')
|
||||||
if (!fromNextcloud) setTab('local')
|
|
||||||
if (fromNextcloud) doUpload(f)
|
|
||||||
} else {
|
} else {
|
||||||
setError('Please select a PDF file')
|
setError('Please select a PDF file')
|
||||||
}
|
}
|
||||||
|
|
@ -174,22 +57,9 @@ export default function UploadPage() {
|
||||||
Upload a PDF file (up to 500MB) to generate interactive quizzes.
|
Upload a PDF file (up to 500MB) to generate interactive quizzes.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Tab selector */}
|
|
||||||
<div style={{ display: 'flex', gap: 4, marginBottom: 20 }}>
|
|
||||||
<button className={`btn btn-sm ${tab === 'local' ? 'btn-primary' : 'btn-secondary'}`}
|
|
||||||
onClick={() => setTab('local')}>
|
|
||||||
💻 Local File
|
|
||||||
</button>
|
|
||||||
<button className={`btn btn-sm ${tab === 'nextcloud' ? 'btn-primary' : 'btn-secondary'}`}
|
|
||||||
onClick={() => setTab('nextcloud')}>
|
|
||||||
☁️ Nextcloud{!hasNextcloud && ' (not set up)'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <div className="alert alert-error">{error}</div>}
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
|
||||||
{tab === 'local' && (
|
<>
|
||||||
<>
|
|
||||||
<div
|
<div
|
||||||
className={`upload-area ${dragging ? 'dragging' : ''}`}
|
className={`upload-area ${dragging ? 'dragging' : ''}`}
|
||||||
onClick={() => fileRef.current?.click()}
|
onClick={() => fileRef.current?.click()}
|
||||||
|
|
@ -211,20 +81,7 @@ export default function UploadPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<input ref={fileRef} type="file" accept=".pdf" hidden onChange={(e) => handleFile(e.target.files[0])} />
|
<input ref={fileRef} type="file" accept=".pdf" hidden onChange={(e) => handleFile(e.target.files[0])} />
|
||||||
</>
|
</>
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === 'nextcloud' && (
|
|
||||||
<NextcloudBrowser onFile={handleFile} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Selected file from Nextcloud */}
|
|
||||||
{tab === 'nextcloud' && file && (
|
|
||||||
<div style={{ marginTop: 12, padding: '10px 14px', background: 'var(--option-sel-bg)', borderRadius: 8, border: '1px solid var(--option-sel-bd)', fontSize: '0.875rem' }}>
|
|
||||||
Selected: <strong>{file.name}</strong> ({(file.size / 1024 / 1024).toFixed(1)} MB)
|
|
||||||
<button onClick={() => setFile(null)} style={{ marginLeft: 12, background: 'none', border: 'none', color: 'var(--text-muted)', cursor: 'pointer' }}>✕</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{uploading && (
|
{uploading && (
|
||||||
<div style={{ marginTop: 16, padding: '16px', background: 'var(--option-sel-bg)', borderRadius: 8, border: '1px solid var(--option-sel-bd)' }}>
|
<div style={{ marginTop: 16, padding: '16px', background: 'var(--option-sel-bg)', borderRadius: 8, border: '1px solid var(--option-sel-bd)' }}>
|
||||||
|
|
|
||||||
|
|
@ -16,16 +16,17 @@ describe('UploadPage', () => {
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses client-side navigation for the settings link', async () => {
|
it('offers one way in: a file from this machine', async () => {
|
||||||
|
// The Nextcloud tab is gone. It was a per-person cloud integration on a
|
||||||
|
// page only an educator reaches, for a corpus one person loads — and it
|
||||||
|
// asked every one of them for an app password.
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<UploadPage />
|
<UploadPage />
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
)
|
)
|
||||||
|
expect(screen.queryByRole('button', { name: /Nextcloud/ })).toBeNull()
|
||||||
await userEvent.click(screen.getByRole('button', { name: '☁️ Nextcloud (not set up)' }))
|
expect(screen.queryByRole('button', { name: /Local File/ })).toBeNull()
|
||||||
|
expect(screen.getByText(/drag a PDF file here/i)).toBeInTheDocument()
|
||||||
const settingsLink = screen.getByRole('link', { name: 'Go to Settings' })
|
|
||||||
expect(settingsLink).toHaveAttribute('href', '/settings')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue