From fe22372e9d5e11e260f31c769c2ef56ce3d7887f Mon Sep 17 00:00:00 2001 From: Michael Wedl <michael@syslifters.com> Date: Fri, 22 Dec 2023 07:10:46 +0100 Subject: [PATCH] Consolidated history UI for community --- api/src/reportcreator_api/pentests/views.py | 9 ++++----- frontend/src/components/ListView.vue | 6 +++--- frontend/src/composables/api.ts | 19 ++++++++++++------- frontend/src/pages/projects/[projectId].vue | 3 +-- .../pages/projects/[projectId]/history.vue | 11 ++++++++++- 5 files changed, 30 insertions(+), 18 deletions(-) diff --git a/api/src/reportcreator_api/pentests/views.py b/api/src/reportcreator_api/pentests/views.py index d89163b23..1b781bbba 100644 --- a/api/src/reportcreator_api/pentests/views.py +++ b/api/src/reportcreator_api/pentests/views.py @@ -175,14 +175,13 @@ def get_history_timeline_queryset(self): timeline_querysets = self.get_history_timeline_queryset_parts() queryset = None for qs in timeline_querysets: - qs = qs \ - .annotate(history_model=Value(qs.model.instance_type.__name__)) \ - .select_related('history_user') - if 'history_model_order' not in qs.query.annotations: - qs = qs.annotate(history_model_order=F('history_model')) if 'model_id' not in qs.query.annotations: qs = qs.annotate(model_id=Cast(F('id'), output_field=CharField())) + if 'history_model_order' not in qs.query.annotations: + qs = qs.annotate(history_model_order=Value(qs.model.instance_type.__name__)) qs = qs \ + .annotate(history_model=Value(qs.model.instance_type.__name__)) \ + .select_related('history_user') \ .only('history_date', 'history_type', 'history_user', 'history_title', 'history_change_reason', 'id') if queryset is None: queryset = qs diff --git a/frontend/src/components/ListView.vue b/frontend/src/components/ListView.vue index a65ebe09a..2e44ebd82 100644 --- a/frontend/src/components/ListView.vue +++ b/frontend/src/components/ListView.vue @@ -33,7 +33,7 @@ </slot> <page-loader :items="items" class="mt-4" /> <v-list-item - v-if="items.data.value.length === 0 && !items.hasNextPage.value" + v-if="items.data.value.length === 0 && !items.hasNextPage.value && items.hasBaseURL.value" title="No data found" /> </v-list> @@ -45,7 +45,7 @@ import { useSearchableCursorPaginationFetcher } from "~/composables/api"; const props = defineProps<{ - url: string + url: string|null; }>(); const router = useRouter(); @@ -82,7 +82,7 @@ defineExpose({ .list-header { position: sticky; top: 0; - z-index: 1; + z-index: 10; background-color: vuetify.$list-background; } .list-header-actions { diff --git a/frontend/src/composables/api.ts b/frontend/src/composables/api.ts index a900080b4..f29f24cee 100644 --- a/frontend/src/composables/api.ts +++ b/frontend/src/composables/api.ts @@ -23,8 +23,8 @@ export async function useAsyncDataE<T>(handler: (ctx?: NuxtApp) => Promise<T>, o return res.data as Ref<T>; } -export function useCursorPaginationFetcher<T>({ baseURL, query }: { baseURL: string, query?: Object }) { - const searchParams = new URLSearchParams(baseURL.split('?')[1]); +export function useCursorPaginationFetcher<T>({ baseURL, query }: { baseURL: string|null, query?: Object }) { + const searchParams = new URLSearchParams((baseURL || '').split('?')[1]); for (const [k, v] of Object.entries(query || {})) { if (v) { if (Array.isArray(v)) { @@ -39,8 +39,11 @@ export function useCursorPaginationFetcher<T>({ baseURL, query }: { baseURL: str searchParams.delete(k); } } - baseURL = baseURL.split('?')[0] + '?' + searchParams.toString(); + if (baseURL) { + baseURL = baseURL.split('?')[0] + '?' + searchParams.toString(); + } + const hasBaseURL = computed(() => !!baseURL); const nextPageURL = ref<string|null>(baseURL); const hasNextPage = computed(() => !!nextPageURL.value); const pending = ref(false); @@ -75,18 +78,19 @@ export function useCursorPaginationFetcher<T>({ baseURL, query }: { baseURL: str pending, hasError, hasNextPage, + hasBaseURL, fetchNextPage, }; } -export function useSearchableCursorPaginationFetcher<T>(options: { baseURL: string, query?: Object }) { +export function useSearchableCursorPaginationFetcher<T>(options: { baseURL: string|null, query?: Object }) { const initializingFetcher = ref(false); const fetcher = ref<ReturnType<typeof useCursorPaginationFetcher<T>>>(null as any); const fetchNextPageDebounced = debounce(async () => { await fetcher.value.fetchNextPage(); initializingFetcher.value = true; }, 750); - function createFetcher(options: { baseURL: string, query?: Object, fetchInitialPage?: boolean, debounce?: boolean }) { + function createFetcher(options: { baseURL: string|null, query?: Object, fetchInitialPage?: boolean, debounce?: boolean }) { const newFetcher = useCursorPaginationFetcher<T>(options) as any; if (options.fetchInitialPage) { initializingFetcher.value = true; @@ -107,7 +111,7 @@ export function useSearchableCursorPaginationFetcher<T>(options: { baseURL: stri } const currentQuery = computed(() => { - return Object.fromEntries(new URLSearchParams(fetcher.value.baseURL.split('?')[1] || '')); + return Object.fromEntries(new URLSearchParams((fetcher.value.baseURL || '').split('?')[1] || '')); }); function applyFilters(query: Object, { fetchInitialPage = true, debounce = false } = {}) { @@ -123,7 +127,7 @@ export function useSearchableCursorPaginationFetcher<T>(options: { baseURL: stri set: (val: string) => applyFilters({ search: val }, { debounce: true }), }); - function reset(options: { baseURL: string, query?: Object }) { + function reset(options: { baseURL: string|null, query?: Object }) { createFetcher(options); } @@ -136,6 +140,7 @@ export function useSearchableCursorPaginationFetcher<T>(options: { baseURL: stri pending: computed(() => initializingFetcher.value || fetcher.value.pending), hasError: computed(() => fetcher.value.hasError), hasNextPage: computed(() => fetcher.value.hasNextPage), + hasBaseURL: computed(() => fetcher.value.hasBaseURL), currentQuery, search, applyFilters, diff --git a/frontend/src/pages/projects/[projectId].vue b/frontend/src/pages/projects/[projectId].vue index 60c2d231d..9a7137060 100644 --- a/frontend/src/pages/projects/[projectId].vue +++ b/frontend/src/pages/projects/[projectId].vue @@ -17,8 +17,7 @@ <v-list-item :to="`/projects/${project.id}/publish/`" prepend-icon="mdi-earth" title="Publish"> <s-tooltip activator="parent" text="Publish" /> </v-list-item> - <v-list-item :to="`/projects/${project.id}/history/`" prepend-icon="mdi-history"> - <v-list-item-title><pro-info>History</pro-info></v-list-item-title> + <v-list-item :to="`/projects/${project.id}/history/`" prepend-icon="mdi-history" title="History"> <s-tooltip activator="parent" text="History" /> </v-list-item> </template> diff --git a/frontend/src/pages/projects/[projectId]/history.vue b/frontend/src/pages/projects/[projectId]/history.vue index f01ec581c..e360c8161 100644 --- a/frontend/src/pages/projects/[projectId]/history.vue +++ b/frontend/src/pages/projects/[projectId]/history.vue @@ -1,11 +1,15 @@ <template> - <list-view :url="`/api/v1/pentestprojects/${project.id}/history-timeline/?mode=medium`"> + <list-view :url="apiSettings.isProfessionalLicense ? `/api/v1/pentestprojects/${project.id}/history-timeline/?mode=medium` : null"> + <template #title> + <pro-info>Version History</pro-info> + </template> <template #searchbar> <!-- hide searchbar --> <span /> </template> <template #items="{ items }"> <v-timeline + v-if="apiSettings.isProfessionalLicense" direction="vertical" side="end" align="start" @@ -27,12 +31,17 @@ :details="true" /> </v-timeline> + <v-list-item v-else> + Version history is available in SysReptor Professional.<br><br> + See <a href="https://docs.sysreptor.com/features-and-pricing/" target="_blank" class="text-primary">https://docs.sysreptor.com/features-and-pricing/</a> + </v-list-item> </template> </list-view> </template> <script setup lang="ts"> const route = useRoute(); +const apiSettings = useApiSettings(); const projectStore = useProjectStore(); const project = await useAsyncDataE(() => projectStore.getById(route.params.projectId as string));