mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Compare commits
	
		
			4 Commits
		
	
	
		
			5410074062
			...
			fded55dc70
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | fded55dc70 | ||
|   | 20da51278e | ||
|   | 293c84d871 | ||
|   | 1fe8599266 | 
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -176,6 +176,7 @@ | ||||
|             <div class="row"> | ||||
|               <div class="col"> | ||||
|                 <pngx-input-check i18n-title title="Show warning when closing saved views with unsaved changes" formControlName="savedViewsWarnOnUnsavedChange"></pngx-input-check> | ||||
|                 <pngx-input-check i18n-title title="Show document counts in sidebar saved views" formControlName="sidebarViewsShowCount"></pngx-input-check> | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
|   | ||||
| @@ -31,6 +31,7 @@ import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' | ||||
| import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe' | ||||
| import { PermissionsService } from 'src/app/services/permissions.service' | ||||
| import { GroupService } from 'src/app/services/rest/group.service' | ||||
| import { SavedViewService } from 'src/app/services/rest/saved-view.service' | ||||
| import { UserService } from 'src/app/services/rest/user.service' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { SystemStatusService } from 'src/app/services/system-status.service' | ||||
| @@ -72,6 +73,7 @@ describe('SettingsComponent', () => { | ||||
|   let groupService: GroupService | ||||
|   let modalService: NgbModal | ||||
|   let systemStatusService: SystemStatusService | ||||
|   let savedViewsService: SavedViewService | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     TestBed.configureTestingModule({ | ||||
| @@ -122,6 +124,7 @@ describe('SettingsComponent', () => { | ||||
|     permissionsService = TestBed.inject(PermissionsService) | ||||
|     modalService = TestBed.inject(NgbModal) | ||||
|     systemStatusService = TestBed.inject(SystemStatusService) | ||||
|     savedViewsService = TestBed.inject(SavedViewService) | ||||
|     jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) | ||||
|     jest | ||||
|       .spyOn(permissionsService, 'currentUserHasObjectPermissions') | ||||
| @@ -212,7 +215,7 @@ describe('SettingsComponent', () => { | ||||
|     expect(toastErrorSpy).toHaveBeenCalled() | ||||
|     expect(storeSpy).toHaveBeenCalled() | ||||
|     expect(appearanceSettingsSpy).not.toHaveBeenCalled() | ||||
|     expect(setSpy).toHaveBeenCalledTimes(29) | ||||
|     expect(setSpy).toHaveBeenCalledTimes(30) | ||||
|  | ||||
|     // succeed | ||||
|     storeSpy.mockReturnValueOnce(of(true)) | ||||
| @@ -345,4 +348,14 @@ describe('SettingsComponent', () => { | ||||
|     component.reset() | ||||
|     expect(component.settingsForm.get('themeColor').value).toEqual('') | ||||
|   }) | ||||
|  | ||||
|   it('should trigger maybeRefreshDocumentCounts on settings save', () => { | ||||
|     completeSetup() | ||||
|     const maybeRefreshSpy = jest.spyOn( | ||||
|       savedViewsService, | ||||
|       'maybeRefreshDocumentCounts' | ||||
|     ) | ||||
|     settingsService.settingsSaved.emit(true) | ||||
|     expect(maybeRefreshSpy).toHaveBeenCalled() | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -49,6 +49,7 @@ import { | ||||
|   PermissionsService, | ||||
| } from 'src/app/services/permissions.service' | ||||
| import { GroupService } from 'src/app/services/rest/group.service' | ||||
| import { SavedViewService } from 'src/app/services/rest/saved-view.service' | ||||
| import { UserService } from 'src/app/services/rest/user.service' | ||||
| import { | ||||
|   LanguageOption, | ||||
| @@ -117,6 +118,7 @@ export class SettingsComponent | ||||
|   permissionsService = inject(PermissionsService) | ||||
|   private modalService = inject(NgbModal) | ||||
|   private systemStatusService = inject(SystemStatusService) | ||||
|   private savedViewsService = inject(SavedViewService) | ||||
|  | ||||
|   activeNavID: number | ||||
|  | ||||
| @@ -152,6 +154,7 @@ export class SettingsComponent | ||||
|     notificationsConsumerSuppressOnDashboard: new FormControl(null), | ||||
|  | ||||
|     savedViewsWarnOnUnsavedChange: new FormControl(null), | ||||
|     sidebarViewsShowCount: new FormControl(null), | ||||
|   }) | ||||
|  | ||||
|   SettingsNavIDs = SettingsNavIDs | ||||
| @@ -197,6 +200,7 @@ export class SettingsComponent | ||||
|     super() | ||||
|     this.settings.settingsSaved.subscribe(() => { | ||||
|       if (!this.savePending) this.initialize() | ||||
|       this.savedViewsService.maybeRefreshDocumentCounts() | ||||
|     }) | ||||
|   } | ||||
|  | ||||
| @@ -308,6 +312,9 @@ export class SettingsComponent | ||||
|       savedViewsWarnOnUnsavedChange: this.settings.get( | ||||
|         SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE | ||||
|       ), | ||||
|       sidebarViewsShowCount: this.settings.get( | ||||
|         SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT | ||||
|       ), | ||||
|       defaultPermsOwner: this.settings.get(SETTINGS_KEYS.DEFAULT_PERMS_OWNER), | ||||
|       defaultPermsViewUsers: this.settings.get( | ||||
|         SETTINGS_KEYS.DEFAULT_PERMS_VIEW_USERS | ||||
| @@ -485,6 +492,10 @@ export class SettingsComponent | ||||
|       SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE, | ||||
|       this.settingsForm.value.savedViewsWarnOnUnsavedChange | ||||
|     ) | ||||
|     this.settings.set( | ||||
|       SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT, | ||||
|       this.settingsForm.value.sidebarViewsShowCount | ||||
|     ) | ||||
|     this.settings.set( | ||||
|       SETTINGS_KEYS.DEFAULT_PERMS_OWNER, | ||||
|       this.settingsForm.value.defaultPermsOwner | ||||
|   | ||||
| @@ -112,7 +112,14 @@ | ||||
|                     routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name" | ||||
|                     [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" | ||||
|                     popoverClass="popover-slim"> | ||||
|                     <i-bs class="me-1" name="funnel"></i-bs><span> {{view.name}}</span> | ||||
|                     <i-bs class="me-1" name="funnel"></i-bs><span> {{view.name}} | ||||
|                       @if (showSidebarCounts && !slimSidebarEnabled) { | ||||
|                         <span><span class="badge bg-info text-dark ms-2 d-inline">{{ savedViewService.getDocumentCount(view) }}</span></span> | ||||
|                       } | ||||
|                     </span> | ||||
|                     @if (showSidebarCounts && slimSidebarEnabled) { | ||||
|                       <span class="badge bg-info text-dark position-absolute top-0 end-0 d-none d-md-block">{{ savedViewService.getDocumentCount(view) }}</span> | ||||
|                     } | ||||
|                   </a> | ||||
|                   @if (settingsService.organizingSidebarSavedViews) { | ||||
|                     <div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle> | ||||
|   | ||||
| @@ -92,6 +92,7 @@ describe('AppFrameComponent', () => { | ||||
|   let router: Router | ||||
|   let savedViewSpy | ||||
|   let modalService: NgbModal | ||||
|   let maybeRefreshSpy | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     TestBed.configureTestingModule({ | ||||
| @@ -113,7 +114,11 @@ describe('AppFrameComponent', () => { | ||||
|         { | ||||
|           provide: SavedViewService, | ||||
|           useValue: { | ||||
|             reload: () => {}, | ||||
|             reload: (fn: any) => { | ||||
|               if (fn) { | ||||
|                 fn() | ||||
|               } | ||||
|             }, | ||||
|             listAll: () => | ||||
|               of({ | ||||
|                 all: [saved_views.map((v) => v.id)], | ||||
| @@ -121,6 +126,8 @@ describe('AppFrameComponent', () => { | ||||
|                 results: saved_views, | ||||
|               }), | ||||
|             sidebarViews: saved_views.filter((v) => v.show_in_sidebar), | ||||
|             getDocumentCount: (view: SavedView) => 5, | ||||
|             maybeRefreshDocumentCounts: () => {}, | ||||
|           }, | ||||
|         }, | ||||
|         PermissionsService, | ||||
| @@ -169,6 +176,7 @@ describe('AppFrameComponent', () => { | ||||
|     jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) | ||||
|  | ||||
|     savedViewSpy = jest.spyOn(savedViewService, 'reload') | ||||
|     maybeRefreshSpy = jest.spyOn(savedViewService, 'maybeRefreshDocumentCounts') | ||||
|  | ||||
|     fixture = TestBed.createComponent(AppFrameComponent) | ||||
|     component = fixture.componentInstance | ||||
| @@ -359,4 +367,8 @@ describe('AppFrameComponent', () => { | ||||
|     expect(toastErrorSpy).toHaveBeenCalledTimes(2) | ||||
|     expect(toastInfoSpy).toHaveBeenCalledTimes(3) | ||||
|   }) | ||||
|  | ||||
|   it('should call maybeRefreshDocumentCounts after saved views reload', () => { | ||||
|     expect(maybeRefreshSpy).toHaveBeenCalled() | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -102,7 +102,9 @@ export class AppFrameComponent | ||||
|         PermissionType.SavedView | ||||
|       ) | ||||
|     ) { | ||||
|       this.savedViewService.reload() | ||||
|       this.savedViewService.reload(() => { | ||||
|         this.savedViewService.maybeRefreshDocumentCounts() | ||||
|       }) | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -283,4 +285,8 @@ export class AppFrameComponent | ||||
|   onLogout() { | ||||
|     this.openDocumentsService.closeAll() | ||||
|   } | ||||
|  | ||||
|   get showSidebarCounts(): boolean { | ||||
|     return this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| <pngx-widget-frame | ||||
|   *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }" | ||||
|   [title]="savedView.name" | ||||
|   [badge]="count" | ||||
|   [loading]="loading" | ||||
|   [draggable]="savedView" | ||||
|   > | ||||
|   | ||||
| @@ -118,6 +118,8 @@ export class SavedViewWidgetComponent | ||||
|  | ||||
|   displayFields: DisplayField[] = DEFAULT_DASHBOARD_DISPLAY_FIELDS | ||||
|  | ||||
|   count: number | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|     this.reload() | ||||
|     this.displayMode = this.savedView.display_mode ?? DisplayMode.TABLE | ||||
| @@ -178,6 +180,7 @@ export class SavedViewWidgetComponent | ||||
|         tap((result) => { | ||||
|           this.show = true | ||||
|           this.documents = result.results | ||||
|           this.count = result.count | ||||
|         }), | ||||
|         delay(500) | ||||
|       ) | ||||
|   | ||||
| @@ -2,13 +2,16 @@ | ||||
|   <div class="card shadow-sm bg-light fade" [class.show]="show" cdkDrag [cdkDragDisabled]="!draggable" cdkDragPreviewContainer="parent"> | ||||
|     <div class="card-header"> | ||||
|       <div class="d-flex justify-content-between align-items-center"> | ||||
|         <div class="d-flex"> | ||||
|         <div class="d-flex align-items-center"> | ||||
|           @if (draggable) { | ||||
|             <div class="ms-n2 me-1" cdkDragHandle> | ||||
|               <i-bs name="grip-vertical"></i-bs> | ||||
|             </div> | ||||
|           } | ||||
|           <h6 class="card-title mb-0">{{title}}</h6> | ||||
|           @if (badge) { | ||||
|             <span class="badge bg-info text-dark ms-2">{{badge}}</span> | ||||
|           } | ||||
|         </div> | ||||
|         @if (loading) { | ||||
|           <div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div> | ||||
|   | ||||
| @@ -30,6 +30,9 @@ export class WidgetFrameComponent | ||||
|   @Input() | ||||
|   cardless: boolean = false | ||||
|  | ||||
|   @Input() | ||||
|   badge: string | ||||
|  | ||||
|   ngAfterViewInit(): void { | ||||
|     setTimeout(() => { | ||||
|       this.show = true | ||||
|   | ||||
| @@ -73,6 +73,7 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic | ||||
| import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' | ||||
| import { DocumentTypeService } from 'src/app/services/rest/document-type.service' | ||||
| import { DocumentService } from 'src/app/services/rest/document.service' | ||||
| import { SavedViewService } from 'src/app/services/rest/saved-view.service' | ||||
| import { StoragePathService } from 'src/app/services/rest/storage-path.service' | ||||
| import { UserService } from 'src/app/services/rest/user.service' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| @@ -195,6 +196,7 @@ export class DocumentDetailComponent | ||||
|   private hotKeyService = inject(HotKeyService) | ||||
|   private componentRouterService = inject(ComponentRouterService) | ||||
|   private deviceDetectorService = inject(DeviceDetectorService) | ||||
|   private savedViewService = inject(SavedViewService) | ||||
|  | ||||
|   @ViewChild('inputTitle') | ||||
|   titleInput: TextComponent | ||||
| @@ -841,6 +843,7 @@ export class DocumentDetailComponent | ||||
|           } else { | ||||
|             this.openDocumentService.refreshDocument(this.documentId) | ||||
|           } | ||||
|           this.savedViewService.maybeRefreshDocumentCounts() | ||||
|         }, | ||||
|         error: (error) => { | ||||
|           this.networkActive = false | ||||
| @@ -1188,6 +1191,7 @@ export class DocumentDetailComponent | ||||
|   notesUpdated(notes: DocumentNote[]) { | ||||
|     this.document.notes = notes | ||||
|     this.openDocumentService.refreshDocument(this.documentId) | ||||
|     this.savedViewService.maybeRefreshDocumentCounts() | ||||
|   } | ||||
|  | ||||
|   get userIsOwner(): boolean { | ||||
|   | ||||
| @@ -32,6 +32,7 @@ import { | ||||
|   DocumentService, | ||||
|   SelectionDataItem, | ||||
| } from 'src/app/services/rest/document.service' | ||||
| import { SavedViewService } from 'src/app/services/rest/saved-view.service' | ||||
| import { StoragePathService } from 'src/app/services/rest/storage-path.service' | ||||
| import { TagService } from 'src/app/services/rest/tag.service' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| @@ -83,6 +84,7 @@ export class BulkEditorComponent | ||||
|   private storagePathService = inject(StoragePathService) | ||||
|   private customFieldService = inject(CustomFieldsService) | ||||
|   private permissionService = inject(PermissionsService) | ||||
|   private savedViewService = inject(SavedViewService) | ||||
|  | ||||
|   tagSelectionModel = new FilterableDropdownSelectionModel(true) | ||||
|   correspondentSelectionModel = new FilterableDropdownSelectionModel() | ||||
| @@ -270,6 +272,7 @@ export class BulkEditorComponent | ||||
|           this.list.selected.forEach((id) => { | ||||
|             this.openDocumentService.refreshDocument(id) | ||||
|           }) | ||||
|           this.savedViewService.maybeRefreshDocumentCounts() | ||||
|           if (modal) { | ||||
|             modal.close() | ||||
|           } | ||||
|   | ||||
| @@ -58,6 +58,8 @@ export const SETTINGS_KEYS = { | ||||
|     'general-settings:saved-views:dashboard-views-sort-order', | ||||
|   SIDEBAR_VIEWS_SORT_ORDER: | ||||
|     'general-settings:saved-views:sidebar-views-sort-order', | ||||
|   SIDEBAR_VIEWS_SHOW_COUNT: | ||||
|     'general-settings:saved-views:sidebar-views-show-count', | ||||
|   TOUR_COMPLETE: 'general-settings:tour-complete', | ||||
|   DEFAULT_PERMS_OWNER: 'general-settings:permissions:default-owner', | ||||
|   DEFAULT_PERMS_VIEW_USERS: 'general-settings:permissions:default-view-users', | ||||
| @@ -227,6 +229,11 @@ export const SETTINGS: UiSetting[] = [ | ||||
|     type: 'array', | ||||
|     default: [], | ||||
|   }, | ||||
|   { | ||||
|     key: SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT, | ||||
|     type: 'boolean', | ||||
|     default: true, | ||||
|   }, | ||||
|   { | ||||
|     key: SETTINGS_KEYS.APP_LOGO, | ||||
|     type: 'string', | ||||
|   | ||||
| @@ -17,7 +17,7 @@ const saved_views = [ | ||||
|     id: 1, | ||||
|     show_on_dashboard: true, | ||||
|     show_in_sidebar: true, | ||||
|     sort_field: 'name', | ||||
|     sort_field: 'title', | ||||
|     sort_reverse: true, | ||||
|     filter_rules: [], | ||||
|   }, | ||||
| @@ -26,7 +26,7 @@ const saved_views = [ | ||||
|     id: 2, | ||||
|     show_on_dashboard: true, | ||||
|     show_in_sidebar: true, | ||||
|     sort_field: 'name', | ||||
|     sort_field: 'created', | ||||
|     sort_reverse: true, | ||||
|     filter_rules: [], | ||||
|   }, | ||||
| @@ -35,7 +35,7 @@ const saved_views = [ | ||||
|     id: 3, | ||||
|     show_on_dashboard: true, | ||||
|     show_in_sidebar: true, | ||||
|     sort_field: 'name', | ||||
|     sort_field: 'added', | ||||
|     sort_reverse: true, | ||||
|     filter_rules: [], | ||||
|   }, | ||||
| @@ -44,7 +44,7 @@ const saved_views = [ | ||||
|     id: 4, | ||||
|     show_on_dashboard: false, | ||||
|     show_in_sidebar: false, | ||||
|     sort_field: 'name', | ||||
|     sort_field: 'owner', | ||||
|     sort_reverse: true, | ||||
|     filter_rules: [], | ||||
|   }, | ||||
| @@ -222,6 +222,43 @@ describe(`Additional service tests for SavedViewService`, () => { | ||||
|       }) | ||||
|   }) | ||||
|  | ||||
|   it('should accept a callback for reload', () => { | ||||
|     const reloadSpy = jest.fn() | ||||
|     service.reload(reloadSpy) | ||||
|     const req = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000` | ||||
|     ) | ||||
|     req.flush({ | ||||
|       results: saved_views, | ||||
|     }) | ||||
|     expect(reloadSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should support getting document counts for views', () => { | ||||
|     service.maybeRefreshDocumentCounts(saved_views) | ||||
|     saved_views.forEach((saved_view) => { | ||||
|       const req = httpTestingController.expectOne( | ||||
|         `${environment.apiBaseUrl}documents/?page=1&page_size=1&ordering=-${saved_view.sort_field}&fields=id&truncate_content=true` | ||||
|       ) | ||||
|       req.flush({ | ||||
|         all: [], | ||||
|         count: 1, | ||||
|         results: [{ id: 1 }], | ||||
|       }) | ||||
|     }) | ||||
|     expect(service.getDocumentCount(saved_views[0])).toEqual(1) | ||||
|   }) | ||||
|  | ||||
|   it('should not refresh document counts if setting is disabled', () => { | ||||
|     jest.spyOn(settingsService, 'get').mockImplementation((key) => { | ||||
|       if (key === SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) return false | ||||
|     }) | ||||
|     service.maybeRefreshDocumentCounts(saved_views) | ||||
|     httpTestingController.expectNone( | ||||
|       `${environment.apiBaseUrl}documents/?page=1&page_size=1&ordering=-${saved_views[0].sort_field}&fields=id&truncate_content=true` | ||||
|     ) | ||||
|   }) | ||||
|  | ||||
|   beforeEach(() => { | ||||
|     // Dont need to setup again | ||||
|  | ||||
|   | ||||
| @@ -1,12 +1,13 @@ | ||||
| import { HttpClient } from '@angular/common/http' | ||||
| import { inject, Injectable } from '@angular/core' | ||||
| import { combineLatest, Observable } from 'rxjs' | ||||
| import { tap } from 'rxjs/operators' | ||||
| import { combineLatest, Observable, Subject } from 'rxjs' | ||||
| import { takeUntil, tap } from 'rxjs/operators' | ||||
| import { Results } from 'src/app/data/results' | ||||
| import { SavedView } from 'src/app/data/saved-view' | ||||
| import { SETTINGS_KEYS } from 'src/app/data/ui-settings' | ||||
| import { SettingsService } from '../settings.service' | ||||
| import { AbstractPaperlessService } from './abstract-paperless-service' | ||||
| import { DocumentService } from './document.service' | ||||
|  | ||||
| @Injectable({ | ||||
|   providedIn: 'root', | ||||
| @@ -14,9 +15,12 @@ import { AbstractPaperlessService } from './abstract-paperless-service' | ||||
| export class SavedViewService extends AbstractPaperlessService<SavedView> { | ||||
|   protected http: HttpClient | ||||
|   private settingsService = inject(SettingsService) | ||||
|   private documentService = inject(DocumentService) | ||||
|  | ||||
|   public loading: boolean = true | ||||
|   private savedViews: SavedView[] = [] | ||||
|   private savedViewDocumentCounts: Map<number, number> = new Map() | ||||
|   private unsubscribeNotifier: Subject<void> = new Subject<void>() | ||||
|  | ||||
|   constructor() { | ||||
|     super() | ||||
| @@ -46,8 +50,16 @@ export class SavedViewService extends AbstractPaperlessService<SavedView> { | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   public reload() { | ||||
|     this.listAll().subscribe() | ||||
|   public reload(callback: any = null) { | ||||
|     this.listAll() | ||||
|       .pipe( | ||||
|         tap((r) => { | ||||
|           if (callback) { | ||||
|             callback(r) | ||||
|           } | ||||
|         }) | ||||
|       ) | ||||
|       .subscribe() | ||||
|   } | ||||
|  | ||||
|   get allViews() { | ||||
| @@ -110,4 +122,30 @@ export class SavedViewService extends AbstractPaperlessService<SavedView> { | ||||
|   delete(o: SavedView) { | ||||
|     return super.delete(o).pipe(tap(() => this.reload())) | ||||
|   } | ||||
|  | ||||
|   public maybeRefreshDocumentCounts(views: SavedView[] = this.sidebarViews) { | ||||
|     if (!this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT)) { | ||||
|       return | ||||
|     } | ||||
|     this.unsubscribeNotifier.next() // clear previous subscriptions | ||||
|     views.forEach((view) => { | ||||
|       this.documentService | ||||
|         .listFiltered( | ||||
|           1, | ||||
|           1, | ||||
|           view.sort_field, | ||||
|           view.sort_reverse, | ||||
|           view.filter_rules, | ||||
|           { fields: 'id', truncate_content: true } | ||||
|         ) | ||||
|         .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
|         .subscribe((results: Results<Document>) => { | ||||
|           this.savedViewDocumentCounts.set(view.id, results.count) | ||||
|         }) | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   public getDocumentCount(view: SavedView): number { | ||||
|     return this.savedViewDocumentCounts.get(view.id) | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -2,10 +2,12 @@ from __future__ import annotations | ||||
|  | ||||
| import logging | ||||
| import math | ||||
| import re | ||||
| from collections import Counter | ||||
| from contextlib import contextmanager | ||||
| from datetime import datetime | ||||
| from datetime import time | ||||
| from datetime import timedelta | ||||
| from datetime import timezone | ||||
| from shutil import rmtree | ||||
| from typing import TYPE_CHECKING | ||||
| @@ -13,6 +15,8 @@ from typing import Literal | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.utils import timezone as django_timezone | ||||
| from django.utils.timezone import get_current_timezone | ||||
| from django.utils.timezone import now | ||||
| from guardian.shortcuts import get_users_with_perms | ||||
| from whoosh import classify | ||||
| from whoosh import highlight | ||||
| @@ -344,6 +348,7 @@ class LocalDateParser(English): | ||||
| class DelayedFullTextQuery(DelayedQuery): | ||||
|     def _get_query(self) -> tuple: | ||||
|         q_str = self.query_params["query"] | ||||
|         q_str = rewrite_natural_date_keywords(q_str) | ||||
|         qp = MultifieldParser( | ||||
|             [ | ||||
|                 "content", | ||||
| @@ -450,3 +455,37 @@ def get_permissions_criterias(user: User | None = None) -> list: | ||||
|                 query.Term("viewer_id", str(user.id)), | ||||
|             ) | ||||
|     return user_criterias | ||||
|  | ||||
|  | ||||
| def rewrite_natural_date_keywords(query_string: str) -> str: | ||||
|     """ | ||||
|     Rewrites natural date keywords (e.g. added:today or added:"yesterday") to UTC range syntax for Whoosh. | ||||
|     """ | ||||
|  | ||||
|     tz = get_current_timezone() | ||||
|     local_now = now().astimezone(tz) | ||||
|  | ||||
|     today = local_now.date() | ||||
|     yesterday = today - timedelta(days=1) | ||||
|  | ||||
|     ranges = { | ||||
|         "today": ( | ||||
|             datetime.combine(today, time.min, tzinfo=tz), | ||||
|             datetime.combine(today, time.max, tzinfo=tz), | ||||
|         ), | ||||
|         "yesterday": ( | ||||
|             datetime.combine(yesterday, time.min, tzinfo=tz), | ||||
|             datetime.combine(yesterday, time.max, tzinfo=tz), | ||||
|         ), | ||||
|     } | ||||
|  | ||||
|     pattern = r"(\b(?:added|created))\s*:\s*[\"']?(today|yesterday)[\"']?" | ||||
|  | ||||
|     def repl(m): | ||||
|         field, keyword = m.group(1), m.group(2) | ||||
|         start, end = ranges[keyword] | ||||
|         start_str = start.astimezone(timezone.utc).strftime("%Y%m%d%H%M%S") | ||||
|         end_str = end.astimezone(timezone.utc).strftime("%Y%m%d%H%M%S") | ||||
|         return f"{field}:[{start_str} TO {end_str}]" | ||||
|  | ||||
|     return re.sub(pattern, repl, query_string) | ||||
|   | ||||
| @@ -1,6 +1,11 @@ | ||||
| from datetime import datetime | ||||
| from unittest import mock | ||||
|  | ||||
| from django.contrib.auth.models import User | ||||
| from django.test import TestCase | ||||
| from django.test import override_settings | ||||
| from django.utils.timezone import get_current_timezone | ||||
| from django.utils.timezone import timezone | ||||
|  | ||||
| from documents import index | ||||
| from documents.models import Document | ||||
| @@ -90,3 +95,35 @@ class TestAutoComplete(DirectoriesMixin, TestCase): | ||||
|             _, kwargs = mocked_update_doc.call_args | ||||
|  | ||||
|             self.assertIsNone(kwargs["asn"]) | ||||
|  | ||||
|     @override_settings(TIME_ZONE="Pacific/Auckland") | ||||
|     def test_added_today_respects_local_timezone_boundary(self): | ||||
|         tz = get_current_timezone() | ||||
|         fixed_now = datetime(2025, 7, 20, 15, 0, 0, tzinfo=tz) | ||||
|  | ||||
|         # Fake a time near the local boundary (1 AM NZT = 13:00 UTC on previous UTC day) | ||||
|         local_dt = datetime(2025, 7, 20, 1, 0, 0).replace(tzinfo=tz) | ||||
|         utc_dt = local_dt.astimezone(timezone.utc) | ||||
|  | ||||
|         doc = Document.objects.create( | ||||
|             title="Time zone", | ||||
|             content="Testing added:today", | ||||
|             checksum="edgecase123", | ||||
|             added=utc_dt, | ||||
|         ) | ||||
|  | ||||
|         with index.open_index_writer() as writer: | ||||
|             index.update_document(writer, doc) | ||||
|  | ||||
|         superuser = User.objects.create_superuser(username="testuser") | ||||
|         self.client.force_login(superuser) | ||||
|  | ||||
|         with mock.patch("documents.index.now", return_value=fixed_now): | ||||
|             response = self.client.get("/api/documents/?query=added:today") | ||||
|             results = response.json()["results"] | ||||
|             self.assertEqual(len(results), 1) | ||||
|             self.assertEqual(results[0]["id"], doc.id) | ||||
|  | ||||
|             response = self.client.get("/api/documents/?query=added:yesterday") | ||||
|             results = response.json()["results"] | ||||
|             self.assertEqual(len(results), 0) | ||||
|   | ||||
| @@ -1095,7 +1095,14 @@ class DocumentViewSet( | ||||
|  | ||||
| @extend_schema_view( | ||||
|     list=extend_schema( | ||||
|         description="Document views including search", | ||||
|         parameters=[ | ||||
|             OpenApiParameter( | ||||
|                 name="query", | ||||
|                 type=OpenApiTypes.STR, | ||||
|                 location=OpenApiParameter.QUERY, | ||||
|                 description="Advanced search query string", | ||||
|             ), | ||||
|             OpenApiParameter( | ||||
|                 name="full_perms", | ||||
|                 type=OpenApiTypes.BOOL, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user