diff --git a/app/api/routers/admin_dbops.py b/app/api/routers/admin_dbops.py index 0f4d1f6..ce63ea2 100644 --- a/app/api/routers/admin_dbops.py +++ b/app/api/routers/admin_dbops.py @@ -5,7 +5,7 @@ import logging from fastapi import APIRouter, Depends, Path, Query, Request from sqlalchemy.ext.asyncio import AsyncSession -from app.api.deps import get_api_admin_user +from app.api.deps import get_api_admin_user, get_api_current_user from app.api.errors import ApiException from app.api.responses import success_payload from app.api.schemas import ( @@ -185,7 +185,7 @@ async def get_pdf_queue( offset: int | None = Query(default=None, ge=0), status: str | None = Query(default=None), db_session: AsyncSession = Depends(get_db_session), - admin_user: User = Depends(get_api_admin_user), + current_user: User = Depends(get_api_current_user), ): normalized_status = (status or "").strip().lower() or None resolved_page, resolved_limit, resolved_offset = _resolve_pdf_queue_paging( @@ -204,7 +204,7 @@ async def get_pdf_queue( logger, "info", "api.admin.db.pdf_queue_listed", - admin_user_id=int(admin_user.id), + user_id=int(current_user.id), page=int(resolved_page), page_size=int(resolved_limit), offset=int(resolved_offset), diff --git a/app/api/routers/scholars.py b/app/api/routers/scholars.py index 7bbc642..ae3c522 100644 --- a/app/api/routers/scholars.py +++ b/app/api/routers/scholars.py @@ -261,11 +261,18 @@ async def delete_scholar( code="scholar_not_found", message="Scholar not found.", ) - await scholar_service.delete_scholar( - db_session, - profile=profile, - upload_dir=settings.scholar_image_upload_dir, - ) + try: + await scholar_service.delete_scholar( + db_session, + profile=profile, + upload_dir=settings.scholar_image_upload_dir, + ) + except scholar_service.ScholarServiceError as exc: + raise ApiException( + status_code=409, + code="scholar_delete_failed", + message=str(exc), + ) from exc structured_log( logger, "info", "api.scholars.deleted", user_id=current_user.id, scholar_profile_id=scholar_profile_id ) diff --git a/app/services/scholars/application.py b/app/services/scholars/application.py index 028e3fc..75f5e73 100644 --- a/app/services/scholars/application.py +++ b/app/services/scholars/application.py @@ -125,7 +125,11 @@ async def delete_scholar( _safe_remove_upload(upload_root, profile.profile_image_upload_path) await db_session.delete(profile) - await db_session.commit() + try: + await db_session.commit() + except IntegrityError as exc: + await db_session.rollback() + raise ScholarServiceError("Unable to delete scholar due to a database constraint.") from exc async def hydrate_profile_metadata( diff --git a/frontend/src/features/scholars/components/ScholarBatchAdd.test.ts b/frontend/src/features/scholars/components/ScholarBatchAdd.test.ts index 2d76eac..7b25632 100644 --- a/frontend/src/features/scholars/components/ScholarBatchAdd.test.ts +++ b/frontend/src/features/scholars/components/ScholarBatchAdd.test.ts @@ -2,10 +2,144 @@ import { describe, expect, it } from "vitest"; import { mount } from "@vue/test-utils"; import ScholarBatchAdd from "./ScholarBatchAdd.vue"; +import { + parseScholarIds, + parseScholarTokens, + extractScholarIdFromUrl, + validateTokenAsId, +} from "./scholar-batch-parsing"; const defaultProps = { saving: false, loading: false }; -describe("ScholarBatchAdd", () => { +describe("parseScholarIds (unit)", () => { + it("parses a single bare ID", () => { + expect(parseScholarIds("A-UbBTPM15wL")).toEqual(["A-UbBTPM15wL"]); + }); + + it("parses multiple IDs separated by commas", () => { + expect(parseScholarIds("A-UbBTPM15wL, B-UbBTPM15wL")).toEqual(["A-UbBTPM15wL", "B-UbBTPM15wL"]); + }); + + it("deduplicates IDs", () => { + expect(parseScholarIds("A-UbBTPM15wL\nA-UbBTPM15wL")).toEqual(["A-UbBTPM15wL"]); + }); + + it("extracts ID from standard Google Scholar URL", () => { + expect(parseScholarIds("https://scholar.google.com/citations?hl=en&user=A-UbBTPM15wL")).toEqual(["A-UbBTPM15wL"]); + }); + + it("extracts ID from URL with trailing slash", () => { + expect(parseScholarIds("https://scholar.google.com/citations?user=A-UbBTPM15wL/")).toEqual(["A-UbBTPM15wL"]); + }); + + it("extracts ID from URL with fragment", () => { + expect(parseScholarIds("https://scholar.google.com/citations?user=A-UbBTPM15wL#section")).toEqual(["A-UbBTPM15wL"]); + }); + + it("extracts ID from URL with extra query params", () => { + expect(parseScholarIds("https://scholar.google.com/citations?hl=en&user=A-UbBTPM15wL&view_op=list_works&sortby=pubdate")).toEqual(["A-UbBTPM15wL"]); + }); + + it("handles URL-encoded characters in non-ID parts", () => { + expect(parseScholarIds("https://scholar.google.com/citations?hl=en&user=A-UbBTPM15wL&label=%E4%B8%AD%E6%96%87")).toEqual(["A-UbBTPM15wL"]); + }); + + it("rejects malformed IDs (too short)", () => { + expect(parseScholarIds("ABC123")).toEqual([]); + }); + + it("rejects malformed IDs (too long)", () => { + expect(parseScholarIds("ABCDEF1234567")).toEqual([]); + }); + + it("rejects IDs with special characters", () => { + expect(parseScholarIds("ABCDEF12345!")).toEqual([]); + }); + + it("rejects IDs with embedded whitespace", () => { + expect(parseScholarIds("ABC DEF12345")).toEqual([]); + }); + + it("handles mixed valid and invalid tokens", () => { + expect(parseScholarIds("A-UbBTPM15wL, invalid, B-UbBTPM15wL")).toEqual(["A-UbBTPM15wL", "B-UbBTPM15wL"]); + }); + + it("returns empty array for empty string", () => { + expect(parseScholarIds("")).toEqual([]); + }); + + it("returns empty array for whitespace only", () => { + expect(parseScholarIds(" ")).toEqual([]); + }); +}); + +describe("extractScholarIdFromUrl", () => { + it("returns null for non-URL strings", () => { + expect(extractScholarIdFromUrl("not-a-url")).toBeNull(); + }); + + it("returns null for URL without user param", () => { + expect(extractScholarIdFromUrl("https://scholar.google.com/citations?hl=en")).toBeNull(); + }); + + it("extracts from URL with trailing slashes", () => { + expect(extractScholarIdFromUrl("https://scholar.google.com/citations?user=A-UbBTPM15wL///")).toBe("A-UbBTPM15wL"); + }); + + it("strips fragment before parsing", () => { + expect(extractScholarIdFromUrl("https://scholar.google.com/citations?user=A-UbBTPM15wL#foo")).toBe("A-UbBTPM15wL"); + }); + + it("returns null when user param is invalid length", () => { + expect(extractScholarIdFromUrl("https://scholar.google.com/citations?user=short")).toBeNull(); + }); +}); + +describe("validateTokenAsId", () => { + it("returns null for valid 12-char ID", () => { + expect(validateTokenAsId("ABCDEF123456")).toBeNull(); + }); + + it("detects wrong length", () => { + expect(validateTokenAsId("ABC")).toContain("12 characters"); + }); + + it("detects invalid characters", () => { + expect(validateTokenAsId("ABCDEF12345!")).toContain("invalid characters"); + }); + + it("detects whitespace", () => { + expect(validateTokenAsId("ABC DEF12345")).toContain("whitespace"); + }); + + it("detects empty input", () => { + expect(validateTokenAsId("")).toContain("empty"); + }); +}); + +describe("parseScholarTokens (per-token errors)", () => { + it("marks invalid tokens with error messages", () => { + const tokens = parseScholarTokens("A-UbBTPM15wL, short, B-UbBTPM15wL"); + expect(tokens).toHaveLength(3); + expect(tokens[0].id).toBe("A-UbBTPM15wL"); + expect(tokens[0].error).toBeNull(); + expect(tokens[1].id).toBeNull(); + expect(tokens[1].error).toContain("12 characters"); + expect(tokens[2].id).toBe("B-UbBTPM15wL"); + }); + + it("marks duplicate tokens", () => { + const tokens = parseScholarTokens("A-UbBTPM15wL, A-UbBTPM15wL"); + expect(tokens[1].error).toBe("duplicate"); + }); + + it("marks bad URL tokens", () => { + const tokens = parseScholarTokens("https://scholar.google.com/citations?hl=en"); + expect(tokens[0].error).toContain("could not extract"); + }); +}); + +describe("ScholarBatchAdd (component)", () => { it("renders the heading and input", () => { const wrapper = mount(ScholarBatchAdd, { props: defaultProps }); expect(wrapper.text()).toContain("Add Scholar Profiles"); @@ -73,4 +207,18 @@ describe("ScholarBatchAdd", () => { const wrapper = mount(ScholarBatchAdd, { props: { ...defaultProps, saving: true } }); expect(wrapper.text()).toContain("Adding..."); }); + + it("shows validation errors for invalid tokens", async () => { + const wrapper = mount(ScholarBatchAdd, { props: defaultProps }); + await wrapper.find("textarea").setValue("A-UbBTPM15wL, bad!"); + const errors = wrapper.find("[data-testid='validation-errors']"); + expect(errors.exists()).toBe(true); + }); + + it("shows skipped count alongside valid count", async () => { + const wrapper = mount(ScholarBatchAdd, { props: defaultProps }); + await wrapper.find("textarea").setValue("A-UbBTPM15wL, tooshort"); + expect(wrapper.text()).toContain("1 valid"); + expect(wrapper.text()).toContain("1 skipped"); + }); }); diff --git a/frontend/src/features/scholars/components/ScholarBatchAdd.vue b/frontend/src/features/scholars/components/ScholarBatchAdd.vue index bfafbda..60f89b9 100644 --- a/frontend/src/features/scholars/components/ScholarBatchAdd.vue +++ b/frontend/src/features/scholars/components/ScholarBatchAdd.vue @@ -3,6 +3,7 @@ import { computed, ref } from "vue"; import AppButton from "@/components/ui/AppButton.vue"; import AppCard from "@/components/ui/AppCard.vue"; import AppHelpHint from "@/components/ui/AppHelpHint.vue"; +import { parseScholarTokens } from "./scholar-batch-parsing"; defineProps<{ saving: boolean; @@ -15,38 +16,13 @@ const emit = defineEmits<{ const scholarBatchInput = ref(""); -const SCHOLAR_ID_PATTERN = /^[a-zA-Z0-9_-]{12}$/; -const URL_USER_PARAM_PATTERN = /(?:\?|&)user=([a-zA-Z0-9_-]{12})(?:&|#|$)/i; - -function parseScholarIds(raw: string): string[] { - const ordered: string[] = []; - const seen = new Set(); - const tokens = raw.split(/[\s,;]+/).map((v) => v.trim()).filter((v) => v.length > 0); - for (const token of tokens) { - let candidate: string | null = null; - if (SCHOLAR_ID_PATTERN.test(token)) candidate = token; - if (!candidate) { - const directParamMatch = token.match(URL_USER_PARAM_PATTERN); - if (directParamMatch) candidate = directParamMatch[1]; - } - if (!candidate && token.includes("scholar.google")) { - try { - const parsed = new URL(token); - const userParam = parsed.searchParams.get("user"); - if (userParam && SCHOLAR_ID_PATTERN.test(userParam)) candidate = userParam; - } catch (_error) { /* Ignore non-URL tokens. */ } - } - if (!candidate || seen.has(candidate)) continue; - seen.add(candidate); - ordered.push(candidate); - } - return ordered; -} - -const parsedBatchCount = computed(() => parseScholarIds(scholarBatchInput.value).length); +const parsedTokens = computed(() => parseScholarTokens(scholarBatchInput.value)); +const validIds = computed(() => parsedTokens.value.filter((t) => t.id !== null)); +const invalidTokens = computed(() => parsedTokens.value.filter((t) => t.id === null)); +const parsedBatchCount = computed(() => validIds.value.length); function onSubmit(): void { - const ids = parseScholarIds(scholarBatchInput.value); + const ids = validIds.value.map((t) => t.id!); if (ids.length > 0) { emit("add-scholars", ids); scholarBatchInput.value = ""; @@ -76,11 +52,33 @@ function onSubmit(): void { /> -
+

- Parsed IDs: {{ parsedBatchCount }} + {{ parsedBatchCount }} valid ID{{ parsedBatchCount === 1 ? "" : "s" }} +

- +
    +
  • + #{{ item.index }}: {{ item.error }} + {{ item.raw.length > 60 ? item.raw.slice(0, 57) + "..." : item.raw }} +
  • +
  • + +{{ invalidTokens.length - 5 }} more skipped +
  • +
+
+
+ Parsed IDs: 0 +
+ +
+ {{ saving ? "Adding..." : "Add scholars" }}
diff --git a/frontend/src/features/scholars/components/ScholarSettingsModal.vue b/frontend/src/features/scholars/components/ScholarSettingsModal.vue index 4a129f8..77af0ac 100644 --- a/frontend/src/features/scholars/components/ScholarSettingsModal.vue +++ b/frontend/src/features/scholars/components/ScholarSettingsModal.vue @@ -47,7 +47,14 @@ function scholarPublicationsRoute(profile: ScholarProfile): { name: string; quer :image-url="scholar.profile_image_url" />
-

{{ scholarLabel(scholar) }}

+

+ {{ scholarLabel(scholar) }} + Pending +

ID: {{ scholar.scholar_id }}

diff --git a/frontend/src/features/scholars/components/ScholarStatusBadges.vue b/frontend/src/features/scholars/components/ScholarStatusBadges.vue new file mode 100644 index 0000000..86b2cd5 --- /dev/null +++ b/frontend/src/features/scholars/components/ScholarStatusBadges.vue @@ -0,0 +1,29 @@ + + + diff --git a/frontend/src/features/scholars/components/scholar-batch-parsing.ts b/frontend/src/features/scholars/components/scholar-batch-parsing.ts new file mode 100644 index 0000000..45baba7 --- /dev/null +++ b/frontend/src/features/scholars/components/scholar-batch-parsing.ts @@ -0,0 +1,76 @@ +const SCHOLAR_ID_PATTERN = /^[a-zA-Z0-9_-]{12}$/; + +export interface ParsedToken { + index: number; + raw: string; + id: string | null; + error: string | null; +} + +export function extractScholarIdFromUrl(token: string): string | null { + const cleaned = token.replace(/\/+$/, "").replace(/#.*$/, ""); + try { + const parsed = new URL(cleaned); + const userParam = parsed.searchParams.get("user"); + if (!userParam) return null; + const decoded = decodeURIComponent(userParam).trim(); + if (SCHOLAR_ID_PATTERN.test(decoded)) return decoded; + return null; + } catch { + return null; + } +} + +export function validateTokenAsId(token: string): string | null { + const trimmed = token.trim(); + if (!trimmed) return "empty input"; + if (/\s/.test(trimmed)) return "contains whitespace"; + if (trimmed.length !== 12) return `must be 12 characters (got ${trimmed.length})`; + if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) { + return "contains invalid characters (only a-z, A-Z, 0-9, _ and - allowed)"; + } + return null; +} + +export function parseScholarTokens(raw: string): ParsedToken[] { + const tokens = raw.split(/[\s,;]+/).map((v) => v.trim()).filter((v) => v.length > 0); + const results: ParsedToken[] = []; + const seen = new Set(); + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + + if (SCHOLAR_ID_PATTERN.test(token)) { + if (seen.has(token)) { + results.push({ index: i + 1, raw: token, id: null, error: "duplicate" }); + continue; + } + seen.add(token); + results.push({ index: i + 1, raw: token, id: token, error: null }); + continue; + } + + if (token.includes("scholar.google") || token.startsWith("http")) { + const extracted = extractScholarIdFromUrl(token); + if (extracted) { + if (seen.has(extracted)) { + results.push({ index: i + 1, raw: token, id: null, error: "duplicate" }); + continue; + } + seen.add(extracted); + results.push({ index: i + 1, raw: token, id: extracted, error: null }); + continue; + } + results.push({ index: i + 1, raw: token, id: null, error: "could not extract scholar ID from URL" }); + continue; + } + + const reason = validateTokenAsId(token); + results.push({ index: i + 1, raw: token, id: null, error: reason ?? "invalid scholar ID" }); + } + return results; +} + +export function parseScholarIds(raw: string): string[] { + return parseScholarTokens(raw).filter((t) => t.id !== null).map((t) => t.id!); +} diff --git a/frontend/src/features/settings/SettingsAdminPanel.vue b/frontend/src/features/settings/SettingsAdminPanel.vue index b306d1e..6e7e326 100644 --- a/frontend/src/features/settings/SettingsAdminPanel.vue +++ b/frontend/src/features/settings/SettingsAdminPanel.vue @@ -1,7 +1,7 @@