feat(settings): hours interval, pdf queue for all users, fix users tab loading

- Convert check interval setting from minutes to hours in the UI
- Show PDF queue tab to all authenticated users (not admin-only); requeue
  actions remain admin-only in both backend and frontend
- Fix users tab not loading on first visit by removing the AsyncStateGate
  wrapper that prevented child component refs from being populated before
  onMounted fired

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Justin Visser 2026-03-04 02:17:18 +01:00
parent a72151cfdc
commit 0d955c31dc
4 changed files with 43 additions and 46 deletions

View file

@ -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),

View file

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, watch } from "vue"; import { 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,34 +25,21 @@ 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;
} }
} }
@ -72,11 +58,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>

View file

@ -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>

View file

@ -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">