Merge 657b326c95 into c6c89879c9
This commit is contained in:
commit
f8030ad91f
11 changed files with 246 additions and 56 deletions
|
|
@ -5,7 +5,7 @@ import logging
|
||||||
from fastapi import APIRouter, Depends, Path, Query, Request
|
from fastapi import APIRouter, Depends, Path, Query, Request
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.errors import ApiException
|
||||||
from app.api.responses import success_payload
|
from app.api.responses import success_payload
|
||||||
from app.api.schemas import (
|
from app.api.schemas import (
|
||||||
|
|
@ -185,7 +185,7 @@ async def get_pdf_queue(
|
||||||
offset: int | None = Query(default=None, ge=0),
|
offset: int | None = Query(default=None, ge=0),
|
||||||
status: str | None = Query(default=None),
|
status: str | None = Query(default=None),
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
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
|
normalized_status = (status or "").strip().lower() or None
|
||||||
resolved_page, resolved_limit, resolved_offset = _resolve_pdf_queue_paging(
|
resolved_page, resolved_limit, resolved_offset = _resolve_pdf_queue_paging(
|
||||||
|
|
@ -204,7 +204,7 @@ async def get_pdf_queue(
|
||||||
logger,
|
logger,
|
||||||
"info",
|
"info",
|
||||||
"api.admin.db.pdf_queue_listed",
|
"api.admin.db.pdf_queue_listed",
|
||||||
admin_user_id=int(admin_user.id),
|
user_id=int(current_user.id),
|
||||||
page=int(resolved_page),
|
page=int(resolved_page),
|
||||||
page_size=int(resolved_limit),
|
page_size=int(resolved_limit),
|
||||||
offset=int(resolved_offset),
|
offset=int(resolved_offset),
|
||||||
|
|
|
||||||
61
frontend/src/components/patterns/ScrapeSafetyBadge.test.ts
Normal file
61
frontend/src/components/patterns/ScrapeSafetyBadge.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { mount } from "@vue/test-utils";
|
||||||
|
import ScrapeSafetyBadge from "./ScrapeSafetyBadge.vue";
|
||||||
|
import { createDefaultSafetyState, type ScrapeSafetyState } from "@/features/safety";
|
||||||
|
|
||||||
|
function buildState(overrides: Partial<ScrapeSafetyState> = {}): ScrapeSafetyState {
|
||||||
|
return { ...createDefaultSafetyState(), ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ScrapeSafetyBadge", () => {
|
||||||
|
it("shows ready tooltip when cooldown is inactive", () => {
|
||||||
|
const wrapper = mount(ScrapeSafetyBadge, {
|
||||||
|
props: { state: buildState({ cooldown_active: false }) },
|
||||||
|
});
|
||||||
|
expect(wrapper.text()).toContain("Safety ready");
|
||||||
|
const hint = wrapper.findComponent({ name: "AppHelpHint" });
|
||||||
|
expect(hint.exists()).toBe(true);
|
||||||
|
expect(hint.props("text")).toContain("No active cooldown");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows cooldown tooltip with reason and action when active", () => {
|
||||||
|
const wrapper = mount(ScrapeSafetyBadge, {
|
||||||
|
props: {
|
||||||
|
state: buildState({
|
||||||
|
cooldown_active: true,
|
||||||
|
cooldown_reason: "blocked_failure_threshold_exceeded",
|
||||||
|
cooldown_reason_label: "Too many blocked requests",
|
||||||
|
recommended_action: "Wait for cooldown to expire",
|
||||||
|
cooldown_remaining_seconds: 120,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(wrapper.text()).toContain("Safety cooldown");
|
||||||
|
const hint = wrapper.findComponent({ name: "AppHelpHint" });
|
||||||
|
expect(hint.exists()).toBe(true);
|
||||||
|
const text = hint.props("text") as string;
|
||||||
|
expect(text).toContain("Google Scholar rate-limits");
|
||||||
|
expect(text).toContain("Too many blocked requests");
|
||||||
|
expect(text).toContain("Wait for cooldown to expire");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows cooldown tooltip without optional fields", () => {
|
||||||
|
const wrapper = mount(ScrapeSafetyBadge, {
|
||||||
|
props: {
|
||||||
|
state: buildState({
|
||||||
|
cooldown_active: true,
|
||||||
|
cooldown_reason: "some_reason",
|
||||||
|
cooldown_reason_label: null,
|
||||||
|
recommended_action: null,
|
||||||
|
cooldown_remaining_seconds: 60,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const hint = wrapper.findComponent({ name: "AppHelpHint" });
|
||||||
|
const text = hint.props("text") as string;
|
||||||
|
expect(text).toContain("Google Scholar rate-limits");
|
||||||
|
expect(text).not.toContain("Why:");
|
||||||
|
expect(text).not.toContain("Action:");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||||
import { type ScrapeSafetyState } from "@/features/safety";
|
import { type ScrapeSafetyState } from "@/features/safety";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
|
@ -18,10 +19,30 @@ const toneClass = computed(() => {
|
||||||
}
|
}
|
||||||
return "border-state-warning-border bg-state-warning-bg text-state-warning-text";
|
return "border-state-warning-border bg-state-warning-bg text-state-warning-text";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const READY_TOOLTIP = "No active cooldown. Scraping can proceed normally.";
|
||||||
|
const RATE_LIMIT_INTRO =
|
||||||
|
"Google Scholar rate-limits automated requests. The cooldown pauses scraping to avoid your IP being blocked.";
|
||||||
|
|
||||||
|
const tooltipText = computed(() => {
|
||||||
|
if (!props.state.cooldown_active) return READY_TOOLTIP;
|
||||||
|
|
||||||
|
const parts = [RATE_LIMIT_INTRO];
|
||||||
|
if (props.state.cooldown_reason_label) {
|
||||||
|
parts.push(`Why: ${props.state.cooldown_reason_label}`);
|
||||||
|
}
|
||||||
|
if (props.state.recommended_action) {
|
||||||
|
parts.push(`Action: ${props.state.recommended_action}`);
|
||||||
|
}
|
||||||
|
return parts.join(" \u2014 ");
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span class="inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold" :class="toneClass">
|
<span class="inline-flex items-center gap-1">
|
||||||
{{ label }}
|
<span class="inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold" :class="toneClass">
|
||||||
|
{{ label }}
|
||||||
|
</span>
|
||||||
|
<AppHelpHint :text="tooltipText" />
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
|
|
||||||
import AppAlert from "@/components/ui/AppAlert.vue";
|
import AppAlert from "@/components/ui/AppAlert.vue";
|
||||||
|
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||||
import {
|
import {
|
||||||
formatCooldownCountdown,
|
formatCooldownCountdown,
|
||||||
type ScrapeSafetyState,
|
type ScrapeSafetyState,
|
||||||
|
|
@ -79,6 +80,10 @@ const actionText = computed(() => {
|
||||||
return props.safetyState.recommended_action;
|
return props.safetyState.recommended_action;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const bannerHintText = computed(() => {
|
||||||
|
return "Google Scholar rate-limits automated requests. The cooldown pauses scraping to avoid your IP being blocked.";
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
timer = setInterval(() => {
|
timer = setInterval(() => {
|
||||||
now.value = Date.now();
|
now.value = Date.now();
|
||||||
|
|
@ -95,7 +100,12 @@ onBeforeUnmount(() => {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppAlert v-if="isVisible" :tone="tone">
|
<AppAlert v-if="isVisible" :tone="tone">
|
||||||
<template #title>{{ title }}</template>
|
<template #title>
|
||||||
|
<span class="inline-flex items-center gap-1">
|
||||||
|
{{ title }}
|
||||||
|
<AppHelpHint :text="bannerHintText" />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
<p>{{ detailText }}</p>
|
<p>{{ detailText }}</p>
|
||||||
<p v-if="actionText" class="text-secondary">{{ actionText }}</p>
|
<p v-if="actionText" class="text-secondary">{{ actionText }}</p>
|
||||||
</AppAlert>
|
</AppAlert>
|
||||||
|
|
|
||||||
94
frontend/src/features/settings/SettingsAdminPanel.test.ts
Normal file
94
frontend/src/features/settings/SettingsAdminPanel.test.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
import { mount, flushPromises } from "@vue/test-utils";
|
||||||
|
import { nextTick } from "vue";
|
||||||
|
import SettingsAdminPanel from "./SettingsAdminPanel.vue";
|
||||||
|
|
||||||
|
vi.mock("@/features/admin_users", () => ({
|
||||||
|
listAdminUsers: vi.fn().mockResolvedValue([]),
|
||||||
|
createAdminUser: vi.fn(),
|
||||||
|
setAdminUserActive: vi.fn(),
|
||||||
|
resetAdminUserPassword: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/admin_dbops", () => ({
|
||||||
|
getAdminDbIntegrityReport: vi.fn().mockResolvedValue({
|
||||||
|
status: "ok",
|
||||||
|
warnings: [],
|
||||||
|
failures: [],
|
||||||
|
checked_at: null,
|
||||||
|
checks: [],
|
||||||
|
}),
|
||||||
|
listAdminPdfQueue: vi.fn().mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
total_count: 0,
|
||||||
|
has_next: false,
|
||||||
|
has_prev: false,
|
||||||
|
page: 1,
|
||||||
|
page_size: 50,
|
||||||
|
}),
|
||||||
|
requeueAdminPdfLookup: vi.fn(),
|
||||||
|
requeueAllAdminPdfLookups: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/stores/auth", () => ({
|
||||||
|
useAuthStore: () => ({ isAdmin: true }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/admin_repairs", () => ({
|
||||||
|
listAdminRepairTasks: vi.fn().mockResolvedValue([]),
|
||||||
|
runAdminRepairTask: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { listAdminUsers } from "@/features/admin_users";
|
||||||
|
import { getAdminDbIntegrityReport, listAdminPdfQueue } from "@/features/admin_dbops";
|
||||||
|
|
||||||
|
const mockedListUsers = vi.mocked(listAdminUsers);
|
||||||
|
const mockedGetReport = vi.mocked(getAdminDbIntegrityReport);
|
||||||
|
const mockedListPdfQueue = vi.mocked(listAdminPdfQueue);
|
||||||
|
|
||||||
|
describe("SettingsAdminPanel", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedListUsers.mockClear();
|
||||||
|
mockedGetReport.mockClear();
|
||||||
|
mockedListPdfQueue.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls load on users section after nextTick when section=users", async () => {
|
||||||
|
mount(SettingsAdminPanel, { props: { section: "users" } });
|
||||||
|
await nextTick();
|
||||||
|
await nextTick();
|
||||||
|
await flushPromises();
|
||||||
|
expect(mockedListUsers).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls load on integrity section after nextTick when section=integrity", async () => {
|
||||||
|
mount(SettingsAdminPanel, { props: { section: "integrity" } });
|
||||||
|
await nextTick();
|
||||||
|
await nextTick();
|
||||||
|
await flushPromises();
|
||||||
|
expect(mockedGetReport).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls load on new section when section prop changes", async () => {
|
||||||
|
const wrapper = mount(SettingsAdminPanel, { props: { section: "users" } });
|
||||||
|
await nextTick();
|
||||||
|
await nextTick();
|
||||||
|
await flushPromises();
|
||||||
|
mockedListUsers.mockClear();
|
||||||
|
mockedGetReport.mockClear();
|
||||||
|
|
||||||
|
await wrapper.setProps({ section: "integrity" });
|
||||||
|
await nextTick();
|
||||||
|
await flushPromises();
|
||||||
|
expect(mockedGetReport).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls load on pdf section when section=pdf", async () => {
|
||||||
|
mount(SettingsAdminPanel, { props: { section: "pdf" } });
|
||||||
|
await nextTick();
|
||||||
|
await nextTick();
|
||||||
|
await flushPromises();
|
||||||
|
expect(mockedListPdfQueue).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, watch } from "vue";
|
import { nextTick, onMounted, watch } from "vue";
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
|
|
||||||
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
|
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
|
||||||
import AdminIntegritySection from "@/features/settings/components/AdminIntegritySection.vue";
|
import AdminIntegritySection from "@/features/settings/components/AdminIntegritySection.vue";
|
||||||
import AdminPdfQueueSection from "@/features/settings/components/AdminPdfQueueSection.vue";
|
import AdminPdfQueueSection from "@/features/settings/components/AdminPdfQueueSection.vue";
|
||||||
|
|
@ -18,7 +18,6 @@ const props = defineProps<{
|
||||||
section: "users" | "integrity" | "repairs" | "pdf";
|
section: "users" | "integrity" | "repairs" | "pdf";
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const loading = ref(true);
|
|
||||||
const { errorMessage, errorRequestId, successMessage, clearAlerts, assignError } = useRequestState();
|
const { errorMessage, errorRequestId, successMessage, clearAlerts, assignError } = useRequestState();
|
||||||
|
|
||||||
const usersRef = ref<InstanceType<typeof AdminUsersSection> | null>(null);
|
const usersRef = ref<InstanceType<typeof AdminUsersSection> | null>(null);
|
||||||
|
|
@ -26,39 +25,28 @@ const integrityRef = ref<InstanceType<typeof AdminIntegritySection> | null>(null
|
||||||
const repairsRef = ref<InstanceType<typeof AdminRepairsSection> | null>(null);
|
const repairsRef = ref<InstanceType<typeof AdminRepairsSection> | null>(null);
|
||||||
const pdfQueueRef = ref<InstanceType<typeof AdminPdfQueueSection> | null>(null);
|
const pdfQueueRef = ref<InstanceType<typeof AdminPdfQueueSection> | null>(null);
|
||||||
|
|
||||||
async function refreshForSection(): Promise<void> {
|
|
||||||
if (props.section === SECTION_USERS && usersRef.value) {
|
|
||||||
await usersRef.value.load();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (props.section === SECTION_INTEGRITY && integrityRef.value) {
|
|
||||||
await integrityRef.value.load();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (props.section === SECTION_REPAIRS) {
|
|
||||||
await usersRef.value?.load();
|
|
||||||
await repairsRef.value?.load();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (props.section === SECTION_PDF && pdfQueueRef.value) {
|
|
||||||
await pdfQueueRef.value.load();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSection(): Promise<void> {
|
async function loadSection(): Promise<void> {
|
||||||
loading.value = true;
|
|
||||||
clearAlerts();
|
clearAlerts();
|
||||||
try {
|
try {
|
||||||
await refreshForSection();
|
if (props.section === SECTION_USERS && usersRef.value) {
|
||||||
|
await usersRef.value.load();
|
||||||
|
} else if (props.section === SECTION_INTEGRITY && integrityRef.value) {
|
||||||
|
await integrityRef.value.load();
|
||||||
|
} else if (props.section === SECTION_REPAIRS) {
|
||||||
|
await usersRef.value?.load();
|
||||||
|
await repairsRef.value?.load();
|
||||||
|
} else if (props.section === SECTION_PDF && pdfQueueRef.value) {
|
||||||
|
await pdfQueueRef.value.load();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
assignError(error, "Unable to load admin data.");
|
assignError(error, "Unable to load admin data.");
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadSection);
|
onMounted(() => {
|
||||||
watch(() => props.section, loadSection);
|
nextTick(loadSection);
|
||||||
|
});
|
||||||
|
watch(() => props.section, loadSection, { flush: "post" });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -72,11 +60,9 @@ watch(() => props.section, loadSection);
|
||||||
@dismiss-success="successMessage = null"
|
@dismiss-success="successMessage = null"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AsyncStateGate :loading="loading" :loading-lines="10" :show-empty="false">
|
<AdminUsersSection v-if="props.section === SECTION_USERS || props.section === SECTION_REPAIRS" v-show="props.section === SECTION_USERS" ref="usersRef" />
|
||||||
<AdminUsersSection v-if="props.section === SECTION_USERS || props.section === SECTION_REPAIRS" v-show="props.section === SECTION_USERS" ref="usersRef" />
|
<AdminIntegritySection v-if="props.section === SECTION_INTEGRITY" ref="integrityRef" />
|
||||||
<AdminIntegritySection v-if="props.section === SECTION_INTEGRITY" ref="integrityRef" />
|
<AdminRepairsSection v-if="props.section === SECTION_REPAIRS" ref="repairsRef" :users="usersRef?.users ?? []" />
|
||||||
<AdminRepairsSection v-if="props.section === SECTION_REPAIRS" ref="repairsRef" :users="usersRef?.users ?? []" />
|
<AdminPdfQueueSection v-if="props.section === SECTION_PDF" ref="pdfQueueRef" />
|
||||||
<AdminPdfQueueSection v-if="props.section === SECTION_PDF" ref="pdfQueueRef" />
|
|
||||||
</AsyncStateGate>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
import { mount, flushPromises } from "@vue/test-utils";
|
import { mount, flushPromises } from "@vue/test-utils";
|
||||||
|
import { setActivePinia, createPinia } from "pinia";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import AdminPdfQueueSection from "./AdminPdfQueueSection.vue";
|
import AdminPdfQueueSection from "./AdminPdfQueueSection.vue";
|
||||||
|
|
||||||
vi.mock("@/features/admin_dbops", () => ({
|
vi.mock("@/features/admin_dbops", () => ({
|
||||||
|
|
@ -36,6 +38,9 @@ function buildQueueItem(overrides: Record<string, unknown> = {}) {
|
||||||
|
|
||||||
describe("AdminPdfQueueSection", () => {
|
describe("AdminPdfQueueSection", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
const auth = useAuthStore();
|
||||||
|
auth.$patch({ state: "authenticated", user: { id: 1, email: "admin@test.com", is_admin: true, is_active: true } });
|
||||||
mockedListQueue.mockReset();
|
mockedListQueue.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import AppBadge from "@/components/ui/AppBadge.vue";
|
import AppBadge from "@/components/ui/AppBadge.vue";
|
||||||
import AppButton from "@/components/ui/AppButton.vue";
|
import AppButton from "@/components/ui/AppButton.vue";
|
||||||
import AppCard from "@/components/ui/AppCard.vue";
|
import AppCard from "@/components/ui/AppCard.vue";
|
||||||
|
|
@ -17,6 +18,7 @@ import {
|
||||||
import { useRequestState } from "@/composables/useRequestState";
|
import { useRequestState } from "@/composables/useRequestState";
|
||||||
|
|
||||||
const { errorMessage, errorRequestId, successMessage, clearAlerts, assignError, setSuccess } = useRequestState();
|
const { errorMessage, errorRequestId, successMessage, clearAlerts, assignError, setSuccess } = useRequestState();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
|
||||||
const refreshingPdfQueue = ref(false);
|
const refreshingPdfQueue = ref(false);
|
||||||
const requeueingPublicationId = ref<number | null>(null);
|
const requeueingPublicationId = ref<number | null>(null);
|
||||||
|
|
@ -162,7 +164,7 @@ defineExpose({ load });
|
||||||
<option value="50">50 / page</option>
|
<option value="50">50 / page</option>
|
||||||
<option value="100">100 / page</option>
|
<option value="100">100 / page</option>
|
||||||
</AppSelect>
|
</AppSelect>
|
||||||
<AppButton variant="secondary" class="!min-h-8 whitespace-nowrap !px-2.5 !py-1 !text-xs" :disabled="requeueingAllPdfs" title="Queue all missing PDFs" @click="onRequeueAllPdfs">
|
<AppButton v-if="auth.isAdmin" variant="secondary" class="!min-h-8 whitespace-nowrap !px-2.5 !py-1 !text-xs" :disabled="requeueingAllPdfs" title="Queue all missing PDFs" @click="onRequeueAllPdfs">
|
||||||
{{ requeueingAllPdfs ? "Queueing..." : "Queue all" }}
|
{{ requeueingAllPdfs ? "Queueing..." : "Queue all" }}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppRefreshButton variant="secondary" size="sm" :loading="refreshingPdfQueue" title="Refresh PDF queue" loading-title="Refreshing PDF queue" @click="refreshPdfQueue" />
|
<AppRefreshButton variant="secondary" size="sm" :loading="refreshingPdfQueue" title="Refresh PDF queue" loading-title="Refreshing PDF queue" @click="refreshPdfQueue" />
|
||||||
|
|
@ -194,7 +196,7 @@ defineExpose({ load });
|
||||||
<td>{{ formatTimestamp(item.last_attempt_at) }}</td>
|
<td>{{ formatTimestamp(item.last_attempt_at) }}</td>
|
||||||
<td>{{ formatTimestamp(item.resolved_at) }}</td>
|
<td>{{ formatTimestamp(item.resolved_at) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<AppButton variant="ghost" :disabled="requeueingPublicationId === item.publication_id || !canRequeuePdf(item)" @click="onRequeuePdf(item)">
|
<AppButton v-if="auth.isAdmin" variant="ghost" :disabled="requeueingPublicationId === item.publication_id || !canRequeuePdf(item)" @click="onRequeuePdf(item)">
|
||||||
{{ requeueingPublicationId === item.publication_id ? "Requeueing..." : "Requeue" }}
|
{{ requeueingPublicationId === item.publication_id ? "Requeueing..." : "Requeue" }}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -404,7 +404,7 @@ watch(
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<AppButton
|
<AppButton
|
||||||
v-if="auth.isAdmin && runStatus.isLikelyRunning"
|
v-if="runStatus.isLikelyRunning"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
:disabled="!activeRunId || isCancelAnimating"
|
:disabled="!activeRunId || isCancelAnimating"
|
||||||
@click="onCancelRun"
|
@click="onCancelRun"
|
||||||
|
|
@ -420,7 +420,6 @@ watch(
|
||||||
</span>
|
</span>
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton
|
<AppButton
|
||||||
v-if="auth.isAdmin"
|
|
||||||
:disabled="isStartBlocked"
|
:disabled="isStartBlocked"
|
||||||
:title="startCheckDisabledReason || undefined"
|
:title="startCheckDisabledReason || undefined"
|
||||||
:class="isStartCheckAnimating ? 'shadow-[0_0_0_1px_var(--color-state-info-border)]' : ''"
|
:class="isStartCheckAnimating ? 'shadow-[0_0_0_1px_var(--color-state-info-border)]' : ''"
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ const savingScholarHttp = ref(false);
|
||||||
const updatingPassword = ref(false);
|
const updatingPassword = ref(false);
|
||||||
|
|
||||||
const autoRunEnabled = ref(false);
|
const autoRunEnabled = ref(false);
|
||||||
const runIntervalMinutes = ref("60");
|
const runIntervalHours = ref("1");
|
||||||
const requestDelaySeconds = ref("2");
|
const requestDelaySeconds = ref("2");
|
||||||
const navVisiblePages = ref<string[]>([]);
|
const navVisiblePages = ref<string[]>([]);
|
||||||
const openalexApiKey = ref("");
|
const openalexApiKey = ref("");
|
||||||
|
|
@ -77,13 +77,13 @@ const tabItems = computed<AppTabItem[]>(() => {
|
||||||
const tabs: AppTabItem[] = [
|
const tabs: AppTabItem[] = [
|
||||||
{ id: TAB_CHECKING, label: "Checking" },
|
{ id: TAB_CHECKING, label: "Checking" },
|
||||||
{ id: TAB_ACCOUNT, label: "Account" },
|
{ id: TAB_ACCOUNT, label: "Account" },
|
||||||
|
{ id: TAB_ADMIN_PDF, label: "PDF Queue" },
|
||||||
];
|
];
|
||||||
if (auth.isAdmin) {
|
if (auth.isAdmin) {
|
||||||
tabs.push(
|
tabs.push(
|
||||||
{ id: TAB_ADMIN_USERS, label: "Users" },
|
{ id: TAB_ADMIN_USERS, label: "Users" },
|
||||||
{ id: TAB_ADMIN_INTEGRITY, label: "Integrity" },
|
{ id: TAB_ADMIN_INTEGRITY, label: "Integrity" },
|
||||||
{ id: TAB_ADMIN_REPAIRS, label: "Repairs" },
|
{ id: TAB_ADMIN_REPAIRS, label: "Repairs" },
|
||||||
{ id: TAB_ADMIN_PDF, label: "PDF Queue" },
|
|
||||||
{ id: TAB_ADMIN_INTEGRATIONS, label: "Integrations" },
|
{ id: TAB_ADMIN_INTEGRATIONS, label: "Integrations" },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -138,7 +138,7 @@ function hydrateSettings(settings: UserSettings): void {
|
||||||
manualRunAllowed.value = Boolean(settings.policy?.manual_run_allowed ?? true);
|
manualRunAllowed.value = Boolean(settings.policy?.manual_run_allowed ?? true);
|
||||||
|
|
||||||
autoRunEnabled.value = Boolean(settings.auto_run_enabled) && automationAllowed.value;
|
autoRunEnabled.value = Boolean(settings.auto_run_enabled) && automationAllowed.value;
|
||||||
runIntervalMinutes.value = String(settings.run_interval_minutes);
|
runIntervalHours.value = String(Number(settings.run_interval_minutes) / 60);
|
||||||
requestDelaySeconds.value = String(settings.request_delay_seconds);
|
requestDelaySeconds.value = String(settings.request_delay_seconds);
|
||||||
navVisiblePages.value = normalizeUserNavVisiblePages(settings.nav_visible_pages);
|
navVisiblePages.value = normalizeUserNavVisiblePages(settings.nav_visible_pages);
|
||||||
|
|
||||||
|
|
@ -168,6 +168,18 @@ function parseBoundedInteger(value: string, label: string, minimum: number): num
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseHoursToMinutes(value: string, minMinutes: number): number {
|
||||||
|
const hours = Number(value);
|
||||||
|
if (!Number.isFinite(hours) || hours <= 0) {
|
||||||
|
throw new Error("Check interval must be a positive number of hours.");
|
||||||
|
}
|
||||||
|
const minutes = Math.round(hours * 60);
|
||||||
|
if (minutes < minMinutes) {
|
||||||
|
throw new Error(`Check interval must be at least ${minMinutes / 60} hours.`);
|
||||||
|
}
|
||||||
|
return minutes;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadSettings(): Promise<void> {
|
async function loadSettings(): Promise<void> {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
errorMessage.value = null;
|
errorMessage.value = null;
|
||||||
|
|
@ -225,9 +237,8 @@ async function onSaveSettings(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const payload: UserSettingsUpdate = {
|
const payload: UserSettingsUpdate = {
|
||||||
auto_run_enabled: autoRunEnabled.value,
|
auto_run_enabled: autoRunEnabled.value,
|
||||||
run_interval_minutes: parseBoundedInteger(
|
run_interval_minutes: parseHoursToMinutes(
|
||||||
runIntervalMinutes.value,
|
runIntervalHours.value,
|
||||||
"Check interval (minutes)",
|
|
||||||
minCheckIntervalMinutes.value,
|
minCheckIntervalMinutes.value,
|
||||||
),
|
),
|
||||||
request_delay_seconds: parseBoundedInteger(
|
request_delay_seconds: parseBoundedInteger(
|
||||||
|
|
@ -357,11 +368,11 @@ onMounted(async () => {
|
||||||
|
|
||||||
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
||||||
<span class="inline-flex items-center gap-1">
|
<span class="inline-flex items-center gap-1">
|
||||||
Check interval (minutes)
|
Check interval (hours)
|
||||||
<AppHelpHint text="Minimum is controlled by server policy." />
|
<AppHelpHint text="Minimum is controlled by server policy." />
|
||||||
</span>
|
</span>
|
||||||
<AppInput v-model="runIntervalMinutes" inputmode="numeric" />
|
<AppInput v-model="runIntervalHours" inputmode="decimal" />
|
||||||
<span class="text-xs text-secondary">Minimum: {{ minCheckIntervalMinutes }}</span>
|
<span class="text-xs text-secondary">Minimum: {{ minCheckIntervalMinutes / 60 }} hours</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
||||||
|
|
|
||||||
|
|
@ -340,10 +340,11 @@ async def test_api_admin_dbops_forbidden_for_non_admin_and_validates_scope(db_se
|
||||||
assert forbidden_integrity.status_code == 403
|
assert forbidden_integrity.status_code == 403
|
||||||
assert forbidden_integrity.json()["error"]["code"] == "forbidden"
|
assert forbidden_integrity.json()["error"]["code"] == "forbidden"
|
||||||
|
|
||||||
forbidden_pdf_queue = client.get("/api/v1/admin/db/pdf-queue")
|
# PDF queue listing is accessible to all authenticated users (not admin-only).
|
||||||
assert forbidden_pdf_queue.status_code == 403
|
allowed_pdf_queue = client.get("/api/v1/admin/db/pdf-queue")
|
||||||
assert forbidden_pdf_queue.json()["error"]["code"] == "forbidden"
|
assert allowed_pdf_queue.status_code == 200
|
||||||
|
|
||||||
|
# But requeue actions still require admin.
|
||||||
forbidden_requeue = client.post(
|
forbidden_requeue = client.post(
|
||||||
"/api/v1/admin/db/pdf-queue/1/requeue",
|
"/api/v1/admin/db/pdf-queue/1/requeue",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue