diff --git a/src-ui/src/app/components/explorer/explorer.component.html b/src-ui/src/app/components/explorer/explorer.component.html index feb9be68d..0d5db1567 100644 --- a/src-ui/src/app/components/explorer/explorer.component.html +++ b/src-ui/src/app/components/explorer/explorer.component.html @@ -370,7 +370,7 @@ - - - - - {{ d.notes.length }} - - params.has('id')), // only on saved view e.g. /view/id - switchMap((params) => { - return this.savedViewService - .getCached(+params.get('id')) - .pipe(map((view) => ({ view }))) - }) - ) - .pipe(takeUntil(this.unsubscribeNotifier)) - .subscribe(({ view }) => { - if (!view) { - this.router.navigate(['404']) - return - } - this.unmodifiedSavedView = view - this.list.activateSavedViewWithQueryParams( - view, - convertToParamMap(this.route.snapshot.queryParams) - ) - // this.list.reload() - this.unmodifiedFilterRules = view.filter_rules - }) - this.route.queryParamMap - .pipe( - filter(() => !this.route.snapshot.paramMap.has('id')), // only when not on /view/id - takeUntil(this.unsubscribeNotifier) - ) + .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe((queryParams) => { - if (queryParams.has('view')) { - // loading a saved view on /documents - this.loadViewConfig(parseInt(queryParams.get('view'))) - } else { - this.list.activateSavedView(null) - this.list.loadFromQueryParams(queryParams) - this.unmodifiedFilterRules = [] - } + this.list.loadFromQueryParams(queryParams) + this.unmodifiedFilterRules = [] }) } @@ -187,19 +164,10 @@ export class ExplorerComponent this.unsubscribeNotifier.complete() } - loadViewConfig(viewID: number) { - this.savedViewService - .getCached(viewID) - .pipe(first()) - .subscribe((view) => { - this.unmodifiedSavedView = view - this.list.activateSavedView(view) - this.list.reload() - }) - } - - openDocumentDetail(document: PaperlessDocument) { - this.router.navigate(['documents', document.id]) + openDocumentDetail(storagePath: PaperlessStoragePath) { + this.router.navigate(['explorer'], { + queryParams: { spid: storagePath.id }, + }) } toggleSelected(document: PaperlessDocument, event: MouseEvent): void { diff --git a/src-ui/src/app/services/rest/custom-storage-path.service.ts b/src-ui/src/app/services/rest/custom-storage-path.service.ts index 28cd1e23f..fc7343842 100644 --- a/src-ui/src/app/services/rest/custom-storage-path.service.ts +++ b/src-ui/src/app/services/rest/custom-storage-path.service.ts @@ -1,10 +1,7 @@ import { HttpClient } from '@angular/common/http' import { Injectable } from '@angular/core' -import { Observable, map } from 'rxjs' +import { Observable, filter, map, switchMap, tap } from 'rxjs' import { FilterRule } from 'src/app/data/filter-rule' -import { PaperlessDocument } from 'src/app/data/paperless-document' -import { PaperlessDocumentMetadata } from 'src/app/data/paperless-document-metadata' -import { PaperlessDocumentSuggestions } from 'src/app/data/paperless-document-suggestions' import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path' import { Results } from 'src/app/data/results' import { queryParamsFromFilterRules } from 'src/app/utils/query-params' @@ -26,8 +23,6 @@ interface SelectionData { providedIn: 'root', }) export class CustomStoragePathService extends AbstractPaperlessService { - private _searchQuery: string - constructor(http: HttpClient) { super(http, 'storage_paths') } @@ -38,16 +33,40 @@ export class CustomStoragePathService extends AbstractPaperlessService> { - return this.list( - page, - pageSize, - sortField, - sortReverse, - Object.assign(extraParams, queryParamsFromFilterRules(filterRules)) - ).pipe( + const params = Object.assign( + extraParams, + queryParamsFromFilterRules(filterRules) + ) + if (parentStoragePathId !== null && parentStoragePathId !== undefined) { + return this.get(parentStoragePathId).pipe( + switchMap((storagePath) => { + params.path__istartswith = storagePath.path + return this.list(page, pageSize, sortField, sortReverse, params).pipe( + map((results) => { + results.results = results.results.filter((s) => { + const isNotParent = s.id !== parentStoragePathId + const isDirectChild = + s.path + .replace(storagePath.path, '') + .split('/') + .filter((s) => !!s).length === 1 + return isNotParent && isDirectChild + }) + return results + }) + ) + }) + ) + } + + return this.list(page, pageSize, sortField, sortReverse, params).pipe( map((results) => { + results.results = results.results.filter( + (s) => s.path.split('/').length === 1 + ) return results }) ) @@ -58,79 +77,4 @@ export class CustomStoragePathService extends AbstractPaperlessService response.results.map((doc) => doc.id))) } - - getPreviewUrl(id: number, original: boolean = false): string { - let url = this.getResourceUrl(id, 'preview') - if (this._searchQuery) url += `#search="${this._searchQuery}"` - if (original) { - url += '?original=true' - } - return url - } - - getThumbUrl(id: number): string { - return this.getResourceUrl(id, 'thumb') - } - - getDownloadUrl(id: number, original: boolean = false): string { - let url = this.getResourceUrl(id, 'download') - if (original) { - url += '?original=true' - } - return url - } - - update(o: PaperlessDocument): Observable { - // we want to only set created_date - o.created = undefined - return super.update(o) - } - - uploadDocument(formData) { - return this.http.post( - this.getResourceUrl(null, 'post_document'), - formData, - { reportProgress: true, observe: 'events' } - ) - } - - getMetadata(id: number): Observable { - return this.http.get( - this.getResourceUrl(id, 'metadata') - ) - } - - bulkEdit(ids: number[], method: string, args: any) { - return this.http.post(this.getResourceUrl(null, 'bulk_edit'), { - documents: ids, - method: method, - parameters: args, - }) - } - - getSuggestions(id: number): Observable { - return this.http.get( - this.getResourceUrl(id, 'suggestions') - ) - } - - bulkDownload( - ids: number[], - content = 'both', - useFilenameFormatting: boolean = false - ) { - return this.http.post( - this.getResourceUrl(null, 'bulk_download'), - { - documents: ids, - content: content, - follow_formatting: useFilenameFormatting, - }, - { responseType: 'blob' } - ) - } - - public set searchQuery(query: string) { - this._searchQuery = query - } } diff --git a/src-ui/src/app/services/storage-path-list-view.service.ts b/src-ui/src/app/services/storage-path-list-view.service.ts index 2fc2dec14..53dda6dff 100644 --- a/src-ui/src/app/services/storage-path-list-view.service.ts +++ b/src-ui/src/app/services/storage-path-list-view.service.ts @@ -57,6 +57,8 @@ export interface ListViewState { * Contains the IDs of all selected documents. */ selected?: Set + + storagePathId?: number | null } /** @@ -108,6 +110,7 @@ export class StoragePathListViewService { sortReverse: true, filterRules: [], selected: new Set(), + storagePathId: null, } } @@ -121,56 +124,22 @@ export class StoragePathListViewService { return this.listViewStates.get(this._activeSavedViewId) } - activateSavedView(view: PaperlessSavedView) { - this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null - if (view) { - this._activeSavedViewId = view.id - this.loadSavedView(view) - } else { - this._activeSavedViewId = null - } - } - - activateSavedViewWithQueryParams( - view: PaperlessSavedView, - queryParams: ParamMap - ) { - const viewState = paramsToViewState(queryParams) - this.activateSavedView(view) - this.currentPage = viewState.currentPage - } - - loadSavedView(view: PaperlessSavedView, closeCurrentView: boolean = false) { - if (closeCurrentView) { - this._activeSavedViewId = null - } - - this.activeListViewState.filterRules = cloneFilterRules(view.filter_rules) - this.activeListViewState.sortField = view.sort_field - this.activeListViewState.sortReverse = view.sort_reverse - if (this._activeSavedViewId) { - this.activeListViewState.title = view.name - } - - this.reduceSelectionToFilter() - - if (!this.router.routerState.snapshot.url.includes('/view/')) { - this.router.navigate(['view', view.id]) - } - } - loadFromQueryParams(queryParams: ParamMap) { - const paramsEmpty: boolean = queryParams.keys.length == 0 - let newState: ListViewState = this.listViewStates.get( - this._activeSavedViewId - ) - if (!paramsEmpty) newState = paramsToViewState(queryParams) + const isParamsEmpty: boolean = queryParams.keys.length == 0 + let newState: ListViewState & { storagePathId?: number } = + this.listViewStates.get(this._activeSavedViewId) + if (!isParamsEmpty) { + newState = paramsToViewState(queryParams) + if (queryParams.has('spid')) { + newState.storagePathId = parseInt(queryParams.get('spid')) + } + } if (newState == undefined) newState = this.defaultListViewState() // if nothing in local storage // only reload if things have changed if ( !this.initialized || - paramsEmpty || + isParamsEmpty || this.activeListViewState.sortField !== newState.sortField || this.activeListViewState.sortReverse !== newState.sortReverse || this.activeListViewState.currentPage !== newState.currentPage || @@ -183,7 +152,8 @@ export class StoragePathListViewService { this.activeListViewState.sortField = newState.sortField this.activeListViewState.sortReverse = newState.sortReverse this.activeListViewState.currentPage = newState.currentPage - this.reload(null, paramsEmpty) // update the params if there arent any + this.activeListViewState.storagePathId = newState.storagePathId + this.reload(null, isParamsEmpty) // update the params if there arent any } } @@ -198,11 +168,12 @@ export class StoragePathListViewService { activeListViewState.sortField, activeListViewState.sortReverse, activeListViewState.filterRules, - { truncate_content: true } + { truncate_content: true }, + activeListViewState.storagePathId ) .subscribe({ next: (result) => { - console.log('list filtered result:', result) + console.log('result:', result) this.initialized = true this.isReloading = false activeListViewState.collectionSize = result.count diff --git a/src/documents/filters.py b/src/documents/filters.py index 271b91108..fe2414776 100644 --- a/src/documents/filters.py +++ b/src/documents/filters.py @@ -1,159 +1,159 @@ -from django.db.models import Q -from django_filters.rest_framework import BooleanFilter -from django_filters.rest_framework import Filter -from django_filters.rest_framework import FilterSet -from rest_framework_guardian.filters import ObjectPermissionsFilter - -from .models import Correspondent -from .models import Document -from .models import DocumentType -from .models import Log -from .models import StoragePath -from .models import Tag - - -CHAR_KWARGS = ["istartswith", "iendswith", "icontains", "iexact"] -ID_KWARGS = ["in", "exact"] -INT_KWARGS = ["exact", "gt", "gte", "lt", "lte", "isnull"] -DATE_KWARGS = ["year", "month", "day", "date__gt", "gt", "date__lt", "lt"] - - -class CorrespondentFilterSet(FilterSet): - class Meta: - model = Correspondent - fields = {"name": CHAR_KWARGS} - - -class TagFilterSet(FilterSet): - class Meta: - model = Tag - fields = {"name": CHAR_KWARGS} - - -class DocumentTypeFilterSet(FilterSet): - class Meta: - model = DocumentType - fields = {"name": CHAR_KWARGS} - - -class ObjectFilter(Filter): - def __init__(self, exclude=False, in_list=False, field_name=""): - super().__init__() - self.exclude = exclude - self.in_list = in_list - self.field_name = field_name - - def filter(self, qs, value): - if not value: - return qs - - try: - object_ids = [int(x) for x in value.split(",")] - except ValueError: - return qs - - if self.in_list: - qs = qs.filter(**{f"{self.field_name}__id__in": object_ids}).distinct() - else: - for obj_id in object_ids: - if self.exclude: - qs = qs.exclude(**{f"{self.field_name}__id": obj_id}) - else: - qs = qs.filter(**{f"{self.field_name}__id": obj_id}) - - return qs - - -class InboxFilter(Filter): - def filter(self, qs, value): - if value == "true": - return qs.filter(tags__is_inbox_tag=True) - elif value == "false": - return qs.exclude(tags__is_inbox_tag=True) - else: - return qs - - -class TitleContentFilter(Filter): - def filter(self, qs, value): - if value: - return qs.filter(Q(title__icontains=value) | Q(content__icontains=value)) - else: - return qs - - -class DocumentFilterSet(FilterSet): - - is_tagged = BooleanFilter( - label="Is tagged", - field_name="tags", - lookup_expr="isnull", - exclude=True, - ) - - tags__id__all = ObjectFilter(field_name="tags") - - tags__id__none = ObjectFilter(field_name="tags", exclude=True) - - tags__id__in = ObjectFilter(field_name="tags", in_list=True) - - correspondent__id__none = ObjectFilter(field_name="correspondent", exclude=True) - - document_type__id__none = ObjectFilter(field_name="document_type", exclude=True) - - storage_path__id__none = ObjectFilter(field_name="storage_path", exclude=True) - - is_in_inbox = InboxFilter() - - title_content = TitleContentFilter() - - class Meta: - model = Document - fields = { - "title": CHAR_KWARGS, - "content": CHAR_KWARGS, - "archive_serial_number": INT_KWARGS, - "created": DATE_KWARGS, - "added": DATE_KWARGS, - "modified": DATE_KWARGS, - "correspondent": ["isnull"], - "correspondent__id": ID_KWARGS, - "correspondent__name": CHAR_KWARGS, - "tags__id": ID_KWARGS, - "tags__name": CHAR_KWARGS, - "document_type": ["isnull"], - "document_type__id": ID_KWARGS, - "document_type__name": CHAR_KWARGS, - "storage_path": ["isnull"], - "storage_path__id": ID_KWARGS, - "storage_path__name": CHAR_KWARGS, - } - - -class LogFilterSet(FilterSet): - class Meta: - model = Log - fields = {"level": INT_KWARGS, "created": DATE_KWARGS, "group": ID_KWARGS} - - -class StoragePathFilterSet(FilterSet): - class Meta: - model = StoragePath - fields = { - "name": CHAR_KWARGS, - "path": CHAR_KWARGS, - } - - -class ObjectOwnedOrGrantedPermissionsFilter(ObjectPermissionsFilter): - """ - A filter backend that limits results to those where the requesting user - has read object level permissions, owns the objects, or objects without - an owner (for backwards compat) - """ - - def filter_queryset(self, request, queryset, view): - objects_with_perms = super().filter_queryset(request, queryset, view) - objects_owned = queryset.filter(owner=request.user) - objects_unowned = queryset.filter(owner__isnull=True) - return objects_with_perms | objects_owned | objects_unowned +from django.db.models import Q +from django_filters.rest_framework import BooleanFilter +from django_filters.rest_framework import Filter +from django_filters.rest_framework import FilterSet +from rest_framework_guardian.filters import ObjectPermissionsFilter + +from .models import Correspondent +from .models import Document +from .models import DocumentType +from .models import Log +from .models import StoragePath +from .models import Tag + + +CHAR_KWARGS = ["istartswith", "iendswith", "icontains", "iexact"] +ID_KWARGS = ["in", "exact"] +INT_KWARGS = ["exact", "gt", "gte", "lt", "lte", "isnull"] +DATE_KWARGS = ["year", "month", "day", "date__gt", "gt", "date__lt", "lt"] + + +class CorrespondentFilterSet(FilterSet): + class Meta: + model = Correspondent + fields = {"name": CHAR_KWARGS} + + +class TagFilterSet(FilterSet): + class Meta: + model = Tag + fields = {"name": CHAR_KWARGS} + + +class DocumentTypeFilterSet(FilterSet): + class Meta: + model = DocumentType + fields = {"name": CHAR_KWARGS} + + +class ObjectFilter(Filter): + def __init__(self, exclude=False, in_list=False, field_name=""): + super().__init__() + self.exclude = exclude + self.in_list = in_list + self.field_name = field_name + + def filter(self, qs, value): + if not value: + return qs + + try: + object_ids = [int(x) for x in value.split(",")] + except ValueError: + return qs + + if self.in_list: + qs = qs.filter(**{f"{self.field_name}__id__in": object_ids}).distinct() + else: + for obj_id in object_ids: + if self.exclude: + qs = qs.exclude(**{f"{self.field_name}__id": obj_id}) + else: + qs = qs.filter(**{f"{self.field_name}__id": obj_id}) + + return qs + + +class InboxFilter(Filter): + def filter(self, qs, value): + if value == "true": + return qs.filter(tags__is_inbox_tag=True) + elif value == "false": + return qs.exclude(tags__is_inbox_tag=True) + else: + return qs + + +class TitleContentFilter(Filter): + def filter(self, qs, value): + if value: + return qs.filter(Q(title__icontains=value) | Q(content__icontains=value)) + else: + return qs + + +class DocumentFilterSet(FilterSet): + + is_tagged = BooleanFilter( + label="Is tagged", + field_name="tags", + lookup_expr="isnull", + exclude=True, + ) + + tags__id__all = ObjectFilter(field_name="tags") + + tags__id__none = ObjectFilter(field_name="tags", exclude=True) + + tags__id__in = ObjectFilter(field_name="tags", in_list=True) + + correspondent__id__none = ObjectFilter(field_name="correspondent", exclude=True) + + document_type__id__none = ObjectFilter(field_name="document_type", exclude=True) + + storage_path__id__none = ObjectFilter(field_name="storage_path", exclude=True) + + is_in_inbox = InboxFilter() + + title_content = TitleContentFilter() + + class Meta: + model = Document + fields = { + "title": CHAR_KWARGS, + "content": CHAR_KWARGS, + "archive_serial_number": INT_KWARGS, + "created": DATE_KWARGS, + "added": DATE_KWARGS, + "modified": DATE_KWARGS, + "correspondent": ["isnull"], + "correspondent__id": ID_KWARGS, + "correspondent__name": CHAR_KWARGS, + "tags__id": ID_KWARGS, + "tags__name": CHAR_KWARGS, + "document_type": ["isnull"], + "document_type__id": ID_KWARGS, + "document_type__name": CHAR_KWARGS, + "storage_path": ["isnull"], + "storage_path__id": ID_KWARGS, + "storage_path__name": CHAR_KWARGS, + } + + +class LogFilterSet(FilterSet): + class Meta: + model = Log + fields = {"level": INT_KWARGS, "created": DATE_KWARGS, "group": ID_KWARGS} + + +class StoragePathFilterSet(FilterSet): + class Meta: + model = StoragePath + fields = { + "name": CHAR_KWARGS, + "path": CHAR_KWARGS, + } + + +class ObjectOwnedOrGrantedPermissionsFilter(ObjectPermissionsFilter): + """ + A filter backend that limits results to those where the requesting user + has read object level permissions, owns the objects, or objects without + an owner (for backwards compat) + """ + + def filter_queryset(self, request, queryset, view): + objects_with_perms = super().filter_queryset(request, queryset, view) + objects_owned = queryset.filter(owner=request.user) + objects_unowned = queryset.filter(owner__isnull=True) + return objects_with_perms | objects_owned | objects_unowned