mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-31 02:27:10 -04:00 
			
		
		
		
	Enhancement: custom field sorting (#8494)
This commit is contained in:
		
							parent
							
								
									e44cfef662
								
							
						
					
					
						commit
						4e3d25c714
					
				| @ -5942,7 +5942,7 @@ | |||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> |           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||||
|           <context context-type="linenumber">289</context> |           <context context-type="linenumber">294</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="78870852467682010" datatype="html"> |       <trans-unit id="78870852467682010" datatype="html"> | ||||||
| @ -5957,7 +5957,7 @@ | |||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> |           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||||
|           <context context-type="linenumber">329</context> |           <context context-type="linenumber">334</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="157572966557284263" datatype="html"> |       <trans-unit id="157572966557284263" datatype="html"> | ||||||
| @ -5972,7 +5972,7 @@ | |||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> |           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||||
|           <context context-type="linenumber">336</context> |           <context context-type="linenumber">341</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="872092479747931526" datatype="html"> |       <trans-unit id="872092479747931526" datatype="html"> | ||||||
| @ -7209,7 +7209,7 @@ | |||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> |           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||||
|           <context context-type="linenumber">305</context> |           <context context-type="linenumber">310</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="106713086593101376" datatype="html"> |       <trans-unit id="106713086593101376" datatype="html"> | ||||||
| @ -7573,25 +7573,32 @@ | |||||||
|           <context context-type="linenumber">261,263</context> |           <context context-type="linenumber">261,263</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|  |       <trans-unit id="5083658411133224968" datatype="html"> | ||||||
|  |         <source>Sort by <x id="INTERPOLATION" equiv-text="{{getDisplayCustomFieldTitle(field_id)}}"/></source> | ||||||
|  |         <context-group purpose="location"> | ||||||
|  |           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||||
|  |           <context context-type="linenumber">268,269</context> | ||||||
|  |         </context-group> | ||||||
|  |       </trans-unit> | ||||||
|       <trans-unit id="2179847500064178686" datatype="html"> |       <trans-unit id="2179847500064178686" datatype="html"> | ||||||
|         <source>Edit document</source> |         <source>Edit document</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> |           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||||
|           <context context-type="linenumber">297</context> |           <context context-type="linenumber">302</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="3420321797707163677" datatype="html"> |       <trans-unit id="3420321797707163677" datatype="html"> | ||||||
|         <source>Preview document</source> |         <source>Preview document</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> |           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||||
|           <context context-type="linenumber">298</context> |           <context context-type="linenumber">303</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="2807800733729323332" datatype="html"> |       <trans-unit id="2807800733729323332" datatype="html"> | ||||||
|         <source>Yes</source> |         <source>Yes</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> |           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||||
|           <context context-type="linenumber">357</context> |           <context context-type="linenumber">362</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/pipes/yes-no.pipe.ts</context> |           <context context-type="sourcefile">src/app/pipes/yes-no.pipe.ts</context> | ||||||
| @ -7602,7 +7609,7 @@ | |||||||
|         <source>No</source> |         <source>No</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> |           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||||
|           <context context-type="linenumber">357</context> |           <context context-type="linenumber">362</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/pipes/yes-no.pipe.ts</context> |           <context context-type="sourcefile">src/app/pipes/yes-no.pipe.ts</context> | ||||||
|  | |||||||
| @ -262,9 +262,14 @@ | |||||||
|                   Shared |                   Shared | ||||||
|                 </th> |                 </th> | ||||||
|               } |               } | ||||||
|               @for (field of activeDisplayCustomFields; track field) { |               @for (field_id of activeDisplayCustomFields; track field_id) { | ||||||
|                 <th> |                 <th class="cursor-pointer" | ||||||
|                   {{getDisplayCustomFieldTitle(field)}} |                   pngxSortable="{{field_id}}" | ||||||
|  |                   title="Sort by {{getDisplayCustomFieldTitle(field_id)}}" i18n-title | ||||||
|  |                   [currentSortField]="list.sortField" | ||||||
|  |                   [currentSortReverse]="list.sortReverse" | ||||||
|  |                   (sort)="onSort($event)"> | ||||||
|  |                   {{getDisplayCustomFieldTitle(field_id)}} | ||||||
|                 </th> |                 </th> | ||||||
|               } |               } | ||||||
|             </tr> |             </tr> | ||||||
|  | |||||||
| @ -11,6 +11,7 @@ import { SavedView } from 'src/app/data/saved-view' | |||||||
| import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' | import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' | ||||||
| import { PermissionsGuard } from 'src/app/guards/permissions.guard' | import { PermissionsGuard } from 'src/app/guards/permissions.guard' | ||||||
| import { PermissionsService } from 'src/app/services/permissions.service' | import { PermissionsService } from 'src/app/services/permissions.service' | ||||||
|  | import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' | ||||||
| import { SavedViewService } from 'src/app/services/rest/saved-view.service' | import { SavedViewService } from 'src/app/services/rest/saved-view.service' | ||||||
| import { ToastService } from 'src/app/services/toast.service' | import { ToastService } from 'src/app/services/toast.service' | ||||||
| import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component' | import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component' | ||||||
| @ -60,6 +61,17 @@ describe('SavedViewsComponent', () => { | |||||||
|             currentUserCan: () => true, |             currentUserCan: () => true, | ||||||
|           }, |           }, | ||||||
|         }, |         }, | ||||||
|  |         { | ||||||
|  |           provide: CustomFieldsService, | ||||||
|  |           useValue: { | ||||||
|  |             listAll: () => | ||||||
|  |               of({ | ||||||
|  |                 all: [], | ||||||
|  |                 count: 0, | ||||||
|  |                 results: [], | ||||||
|  |               }), | ||||||
|  |           }, | ||||||
|  |         }, | ||||||
|         PermissionsGuard, |         PermissionsGuard, | ||||||
|         provideHttpClient(withInterceptorsFromDi()), |         provideHttpClient(withInterceptorsFromDi()), | ||||||
|         provideHttpClientTesting(), |         provideHttpClientTesting(), | ||||||
|  | |||||||
| @ -156,7 +156,7 @@ describe('DocumentListViewService', () => { | |||||||
|     expect(documentListViewService.currentPage).toEqual(1) |     expect(documentListViewService.currentPage).toEqual(1) | ||||||
|   }) |   }) | ||||||
| 
 | 
 | ||||||
|   it('should handle error on filtering request', () => { |   it('should handle object error on filtering request', () => { | ||||||
|     documentListViewService.currentPage = 1 |     documentListViewService.currentPage = 1 | ||||||
|     const tags__id__in = 'hello' |     const tags__id__in = 'hello' | ||||||
|     const filterRulesAny = [ |     const filterRulesAny = [ | ||||||
| @ -185,6 +185,50 @@ describe('DocumentListViewService', () => { | |||||||
|     ) |     ) | ||||||
|   }) |   }) | ||||||
| 
 | 
 | ||||||
|  |   it('should handle object error on filtering request for custom field sorts', () => { | ||||||
|  |     documentListViewService.currentPage = 1 | ||||||
|  |     documentListViewService.sortField = 'custom_field_999' | ||||||
|  |     let req = httpTestingController.expectOne( | ||||||
|  |       `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-custom_field_999&truncate_content=true` | ||||||
|  |     ) | ||||||
|  |     expect(req.request.method).toEqual('GET') | ||||||
|  |     req.flush( | ||||||
|  |       { custom_field_999: ['Custom field not found'] }, | ||||||
|  |       { status: 400, statusText: 'Unexpected error' } | ||||||
|  |     ) | ||||||
|  |     expect(documentListViewService.error).toEqual( | ||||||
|  |       'custom_field_999: Custom field not found' | ||||||
|  |     ) | ||||||
|  |     // reset the list
 | ||||||
|  |     documentListViewService.sortField = 'created' | ||||||
|  |     req = httpTestingController.expectOne( | ||||||
|  |       `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` | ||||||
|  |     ) | ||||||
|  |   }) | ||||||
|  | 
 | ||||||
|  |   it('should handle string error on filtering request', () => { | ||||||
|  |     documentListViewService.currentPage = 1 | ||||||
|  |     const tags__id__in = 'hello' | ||||||
|  |     const filterRulesAny = [ | ||||||
|  |       { | ||||||
|  |         rule_type: FILTER_HAS_TAGS_ANY, | ||||||
|  |         value: tags__id__in, | ||||||
|  |       }, | ||||||
|  |     ] | ||||||
|  |     documentListViewService.filterRules = filterRulesAny | ||||||
|  |     let req = httpTestingController.expectOne( | ||||||
|  |       `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__in=${tags__id__in}` | ||||||
|  |     ) | ||||||
|  |     expect(req.request.method).toEqual('GET') | ||||||
|  |     req.flush('Generic error', { status: 404, statusText: 'Unexpected error' }) | ||||||
|  |     expect(documentListViewService.error).toEqual('Generic error') | ||||||
|  |     // reset the list
 | ||||||
|  |     documentListViewService.filterRules = [] | ||||||
|  |     req = httpTestingController.expectOne( | ||||||
|  |       `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` | ||||||
|  |     ) | ||||||
|  |   }) | ||||||
|  | 
 | ||||||
|   it('should support setting sort', () => { |   it('should support setting sort', () => { | ||||||
|     expect(documentListViewService.sortField).toEqual('created') |     expect(documentListViewService.sortField).toEqual('created') | ||||||
|     expect(documentListViewService.sortReverse).toBeTruthy() |     expect(documentListViewService.sortReverse).toBeTruthy() | ||||||
|  | |||||||
| @ -307,18 +307,23 @@ export class DocumentListViewService { | |||||||
|             activeListViewState.currentPage = 1 |             activeListViewState.currentPage = 1 | ||||||
|             this.reload() |             this.reload() | ||||||
|           } else { |           } else { | ||||||
|  |             console.log(error) | ||||||
|  | 
 | ||||||
|             this.selectionData = null |             this.selectionData = null | ||||||
|             let errorMessage |             let errorMessage | ||||||
|             if ( |             if ( | ||||||
|               typeof error.error !== 'string' && |               typeof error.error === 'object' && | ||||||
|               Object.keys(error.error).length > 0 |               Object.keys(error.error).length > 0 | ||||||
|             ) { |             ) { | ||||||
|               // e.g. { archive_serial_number: Array<string> }
 |               // e.g. { archive_serial_number: Array<string> }
 | ||||||
|               errorMessage = Object.keys(error.error) |               errorMessage = Object.keys(error.error) | ||||||
|                 .map((fieldName) => { |                 .map((fieldName) => { | ||||||
|  |                   const fieldNameBase = fieldName.split('__')[0] | ||||||
|                   const fieldError: Array<string> = error.error[fieldName] |                   const fieldError: Array<string> = error.error[fieldName] | ||||||
|                   return `${ |                   return `${ | ||||||
|                     this.sortFields.find((f) => f.field == fieldName)?.name |                     this.sortFields.find( | ||||||
|  |                       (f) => f.field?.split('__')[0] == fieldNameBase | ||||||
|  |                     )?.name ?? fieldNameBase | ||||||
|                   }: ${fieldError[0]}` |                   }: ${fieldError[0]}` | ||||||
|                 }) |                 }) | ||||||
|                 .join(', ') |                 .join(', ') | ||||||
|  | |||||||
| @ -4,7 +4,8 @@ import { | |||||||
|   provideHttpClientTesting, |   provideHttpClientTesting, | ||||||
| } from '@angular/common/http/testing' | } from '@angular/common/http/testing' | ||||||
| import { TestBed } from '@angular/core/testing' | import { TestBed } from '@angular/core/testing' | ||||||
| import { Subscription } from 'rxjs' | import { of, Subscription } from 'rxjs' | ||||||
|  | import { CustomFieldDataType } from 'src/app/data/custom-field' | ||||||
| import { | import { | ||||||
|   DOCUMENT_SORT_FIELDS, |   DOCUMENT_SORT_FIELDS, | ||||||
|   DOCUMENT_SORT_FIELDS_FULLTEXT, |   DOCUMENT_SORT_FIELDS_FULLTEXT, | ||||||
| @ -14,12 +15,15 @@ import { SETTINGS_KEYS } from 'src/app/data/ui-settings' | |||||||
| import { environment } from 'src/environments/environment' | import { environment } from 'src/environments/environment' | ||||||
| import { PermissionsService } from '../permissions.service' | import { PermissionsService } from '../permissions.service' | ||||||
| import { SettingsService } from '../settings.service' | import { SettingsService } from '../settings.service' | ||||||
|  | import { CustomFieldsService } from './custom-fields.service' | ||||||
| import { DocumentService } from './document.service' | import { DocumentService } from './document.service' | ||||||
| 
 | 
 | ||||||
| let httpTestingController: HttpTestingController | let httpTestingController: HttpTestingController | ||||||
| let service: DocumentService | let service: DocumentService | ||||||
| let subscription: Subscription | let subscription: Subscription | ||||||
| let settingsService: SettingsService | let settingsService: SettingsService | ||||||
|  | let permissionsService: PermissionsService | ||||||
|  | let customFieldsService: CustomFieldsService | ||||||
| 
 | 
 | ||||||
| const endpoint = 'documents' | const endpoint = 'documents' | ||||||
| const documents = [ | const documents = [ | ||||||
| @ -55,8 +59,29 @@ beforeEach(() => { | |||||||
|   }) |   }) | ||||||
| 
 | 
 | ||||||
|   httpTestingController = TestBed.inject(HttpTestingController) |   httpTestingController = TestBed.inject(HttpTestingController) | ||||||
|   service = TestBed.inject(DocumentService) |  | ||||||
|   settingsService = TestBed.inject(SettingsService) |   settingsService = TestBed.inject(SettingsService) | ||||||
|  |   customFieldsService = TestBed.inject(CustomFieldsService) | ||||||
|  |   permissionsService = TestBed.inject(PermissionsService) | ||||||
|  |   jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) | ||||||
|  |   jest.spyOn(customFieldsService, 'listAll').mockReturnValue( | ||||||
|  |     of({ | ||||||
|  |       all: [1, 2, 3], | ||||||
|  |       count: 3, | ||||||
|  |       results: [ | ||||||
|  |         { | ||||||
|  |           id: 1, | ||||||
|  |           name: 'Custom Field 1', | ||||||
|  |           data_type: CustomFieldDataType.String, | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           id: 2, | ||||||
|  |           name: 'Custom Field 2', | ||||||
|  |           data_type: CustomFieldDataType.Integer, | ||||||
|  |         }, | ||||||
|  |       ], | ||||||
|  |     }) | ||||||
|  |   ) | ||||||
|  |   service = TestBed.inject(DocumentService) | ||||||
| }) | }) | ||||||
| 
 | 
 | ||||||
| describe(`DocumentService`, () => { | describe(`DocumentService`, () => { | ||||||
| @ -289,18 +314,25 @@ describe(`DocumentService`, () => { | |||||||
| it('should construct sort fields respecting permissions', () => { | it('should construct sort fields respecting permissions', () => { | ||||||
|   expect( |   expect( | ||||||
|     service.sortFields.find((f) => f.field === 'correspondent__name') |     service.sortFields.find((f) => f.field === 'correspondent__name') | ||||||
|   ).toBeUndefined() |   ).not.toBeUndefined() | ||||||
|   expect( |   expect( | ||||||
|     service.sortFields.find((f) => f.field === 'document_type__name') |     service.sortFields.find((f) => f.field === 'document_type__name') | ||||||
|   ).toBeUndefined() |   ).not.toBeUndefined() | ||||||
|  |   expect( | ||||||
|  |     service.sortFields.find((f) => f.field === 'owner') | ||||||
|  |   ).not.toBeUndefined() | ||||||
| 
 | 
 | ||||||
|   const permissionsService: PermissionsService = |   jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false) | ||||||
|     TestBed.inject(PermissionsService) |  | ||||||
|   jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) |  | ||||||
|   service['setupSortFields']() |   service['setupSortFields']() | ||||||
|   expect(service.sortFields).toEqual(DOCUMENT_SORT_FIELDS) |   const fields = DOCUMENT_SORT_FIELDS.filter( | ||||||
|  |     (f) => | ||||||
|  |       ['correspondent__name', 'document_type__name', 'owner'].indexOf( | ||||||
|  |         f.field | ||||||
|  |       ) === -1 | ||||||
|  |   ) | ||||||
|  |   expect(service.sortFields).toEqual(fields) | ||||||
|   expect(service.sortFieldsFullText).toEqual([ |   expect(service.sortFieldsFullText).toEqual([ | ||||||
|     ...DOCUMENT_SORT_FIELDS, |     ...fields, | ||||||
|     ...DOCUMENT_SORT_FIELDS_FULLTEXT, |     ...DOCUMENT_SORT_FIELDS_FULLTEXT, | ||||||
|   ]) |   ]) | ||||||
| 
 | 
 | ||||||
| @ -311,6 +343,38 @@ it('should construct sort fields respecting permissions', () => { | |||||||
|   ).toBeUndefined() |   ).toBeUndefined() | ||||||
| }) | }) | ||||||
| 
 | 
 | ||||||
|  | it('should include custom fields in sort fields if user has permission', () => { | ||||||
|  |   const permissionsService: PermissionsService = | ||||||
|  |     TestBed.inject(PermissionsService) | ||||||
|  |   jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) | ||||||
|  | 
 | ||||||
|  |   service['customFields'] = [ | ||||||
|  |     { | ||||||
|  |       id: 1, | ||||||
|  |       name: 'Custom Field 1', | ||||||
|  |       data_type: CustomFieldDataType.String, | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 2, | ||||||
|  |       name: 'Custom Field 2', | ||||||
|  |       data_type: CustomFieldDataType.Integer, | ||||||
|  |     }, | ||||||
|  |   ] | ||||||
|  | 
 | ||||||
|  |   service['setupSortFields']() | ||||||
|  |   expect(service.sortFields).toEqual([ | ||||||
|  |     ...DOCUMENT_SORT_FIELDS, | ||||||
|  |     { | ||||||
|  |       field: 'custom_field_1', | ||||||
|  |       name: 'Custom Field 1', | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       field: 'custom_field_2', | ||||||
|  |       name: 'Custom Field 2', | ||||||
|  |     }, | ||||||
|  |   ]) | ||||||
|  | }) | ||||||
|  | 
 | ||||||
| afterEach(() => { | afterEach(() => { | ||||||
|   subscription?.unsubscribe() |   subscription?.unsubscribe() | ||||||
|   httpTestingController.verify() |   httpTestingController.verify() | ||||||
|  | |||||||
| @ -3,6 +3,7 @@ import { Injectable } from '@angular/core' | |||||||
| import { Observable } from 'rxjs' | import { Observable } from 'rxjs' | ||||||
| import { map, tap } from 'rxjs/operators' | import { map, tap } from 'rxjs/operators' | ||||||
| import { AuditLogEntry } from 'src/app/data/auditlog-entry' | import { AuditLogEntry } from 'src/app/data/auditlog-entry' | ||||||
|  | import { CustomField } from 'src/app/data/custom-field' | ||||||
| import { | import { | ||||||
|   DOCUMENT_SORT_FIELDS, |   DOCUMENT_SORT_FIELDS, | ||||||
|   DOCUMENT_SORT_FIELDS_FULLTEXT, |   DOCUMENT_SORT_FIELDS_FULLTEXT, | ||||||
| @ -22,6 +23,7 @@ import { | |||||||
| import { SettingsService } from '../settings.service' | import { SettingsService } from '../settings.service' | ||||||
| import { AbstractPaperlessService } from './abstract-paperless-service' | import { AbstractPaperlessService } from './abstract-paperless-service' | ||||||
| import { CorrespondentService } from './correspondent.service' | import { CorrespondentService } from './correspondent.service' | ||||||
|  | import { CustomFieldsService } from './custom-fields.service' | ||||||
| import { DocumentTypeService } from './document-type.service' | import { DocumentTypeService } from './document-type.service' | ||||||
| import { StoragePathService } from './storage-path.service' | import { StoragePathService } from './storage-path.service' | ||||||
| import { TagService } from './tag.service' | import { TagService } from './tag.service' | ||||||
| @ -55,6 +57,8 @@ export class DocumentService extends AbstractPaperlessService<Document> { | |||||||
|     return this._sortFieldsFullText |     return this._sortFieldsFullText | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   private customFields: CustomField[] = [] | ||||||
|  | 
 | ||||||
|   constructor( |   constructor( | ||||||
|     http: HttpClient, |     http: HttpClient, | ||||||
|     private correspondentService: CorrespondentService, |     private correspondentService: CorrespondentService, | ||||||
| @ -62,14 +66,40 @@ export class DocumentService extends AbstractPaperlessService<Document> { | |||||||
|     private tagService: TagService, |     private tagService: TagService, | ||||||
|     private storagePathService: StoragePathService, |     private storagePathService: StoragePathService, | ||||||
|     private permissionsService: PermissionsService, |     private permissionsService: PermissionsService, | ||||||
|     private settingsService: SettingsService |     private settingsService: SettingsService, | ||||||
|  |     private customFieldService: CustomFieldsService | ||||||
|   ) { |   ) { | ||||||
|     super(http, 'documents') |     super(http, 'documents') | ||||||
|  |     if ( | ||||||
|  |       this.permissionsService.currentUserCan( | ||||||
|  |         PermissionAction.View, | ||||||
|  |         PermissionType.CustomField | ||||||
|  |       ) | ||||||
|  |     ) { | ||||||
|  |       this.customFieldService.listAll().subscribe((fields) => { | ||||||
|  |         this.customFields = fields.results | ||||||
|  |         this.setupSortFields() | ||||||
|  |       }) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     this.setupSortFields() |     this.setupSortFields() | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   private setupSortFields() { |   private setupSortFields() { | ||||||
|     this._sortFields = [...DOCUMENT_SORT_FIELDS] |     this._sortFields = [...DOCUMENT_SORT_FIELDS] | ||||||
|  |     if ( | ||||||
|  |       this.permissionsService.currentUserCan( | ||||||
|  |         PermissionAction.View, | ||||||
|  |         PermissionType.CustomField | ||||||
|  |       ) | ||||||
|  |     ) { | ||||||
|  |       this.customFields.forEach((field) => { | ||||||
|  |         this._sortFields.push({ | ||||||
|  |           field: `custom_field_${field.id}`, | ||||||
|  |           name: field.name, | ||||||
|  |         }) | ||||||
|  |       }) | ||||||
|  |     } | ||||||
|     let excludes = [] |     let excludes = [] | ||||||
|     if ( |     if ( | ||||||
|       !this.permissionsService.currentUserCan( |       !this.permissionsService.currentUserCan( | ||||||
|  | |||||||
| @ -6,10 +6,17 @@ from collections.abc import Callable | |||||||
| from contextlib import contextmanager | from contextlib import contextmanager | ||||||
| 
 | 
 | ||||||
| from django.contrib.contenttypes.models import ContentType | from django.contrib.contenttypes.models import ContentType | ||||||
|  | from django.db.models import Case | ||||||
| from django.db.models import CharField | from django.db.models import CharField | ||||||
| from django.db.models import Count | from django.db.models import Count | ||||||
|  | from django.db.models import Exists | ||||||
|  | from django.db.models import IntegerField | ||||||
| from django.db.models import OuterRef | from django.db.models import OuterRef | ||||||
| from django.db.models import Q | from django.db.models import Q | ||||||
|  | from django.db.models import Subquery | ||||||
|  | from django.db.models import Sum | ||||||
|  | from django.db.models import Value | ||||||
|  | from django.db.models import When | ||||||
| from django.db.models.functions import Cast | from django.db.models.functions import Cast | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from django_filters.rest_framework import BooleanFilter | from django_filters.rest_framework import BooleanFilter | ||||||
| @ -18,6 +25,7 @@ from django_filters.rest_framework import FilterSet | |||||||
| from guardian.utils import get_group_obj_perms_model | from guardian.utils import get_group_obj_perms_model | ||||||
| from guardian.utils import get_user_obj_perms_model | from guardian.utils import get_user_obj_perms_model | ||||||
| from rest_framework import serializers | from rest_framework import serializers | ||||||
|  | from rest_framework.filters import OrderingFilter | ||||||
| from rest_framework_guardian.filters import ObjectPermissionsFilter | from rest_framework_guardian.filters import ObjectPermissionsFilter | ||||||
| 
 | 
 | ||||||
| from documents.models import Correspondent | from documents.models import Correspondent | ||||||
| @ -760,3 +768,141 @@ class ObjectOwnedPermissionsFilter(ObjectPermissionsFilter): | |||||||
|         objects_owned = queryset.filter(owner=request.user) |         objects_owned = queryset.filter(owner=request.user) | ||||||
|         objects_unowned = queryset.filter(owner__isnull=True) |         objects_unowned = queryset.filter(owner__isnull=True) | ||||||
|         return objects_owned | objects_unowned |         return objects_owned | objects_unowned | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class DocumentsOrderingFilter(OrderingFilter): | ||||||
|  |     field_name = "ordering" | ||||||
|  |     prefix = "custom_field_" | ||||||
|  | 
 | ||||||
|  |     def filter_queryset(self, request, queryset, view): | ||||||
|  |         param = request.query_params.get("ordering") | ||||||
|  |         if param and self.prefix in param: | ||||||
|  |             custom_field_id = int(param.split(self.prefix)[1]) | ||||||
|  |             try: | ||||||
|  |                 field = CustomField.objects.get(pk=custom_field_id) | ||||||
|  |             except CustomField.DoesNotExist: | ||||||
|  |                 raise serializers.ValidationError( | ||||||
|  |                     {self.prefix + str(custom_field_id): [_("Custom field not found")]}, | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |             annotation = None | ||||||
|  |             match field.data_type: | ||||||
|  |                 case CustomField.FieldDataType.STRING: | ||||||
|  |                     annotation = Subquery( | ||||||
|  |                         CustomFieldInstance.objects.filter( | ||||||
|  |                             document_id=OuterRef("id"), | ||||||
|  |                             field_id=custom_field_id, | ||||||
|  |                         ).values("value_text")[:1], | ||||||
|  |                     ) | ||||||
|  |                 case CustomField.FieldDataType.INT: | ||||||
|  |                     annotation = Subquery( | ||||||
|  |                         CustomFieldInstance.objects.filter( | ||||||
|  |                             document_id=OuterRef("id"), | ||||||
|  |                             field_id=custom_field_id, | ||||||
|  |                         ).values("value_int")[:1], | ||||||
|  |                     ) | ||||||
|  |                 case CustomField.FieldDataType.FLOAT: | ||||||
|  |                     annotation = Subquery( | ||||||
|  |                         CustomFieldInstance.objects.filter( | ||||||
|  |                             document_id=OuterRef("id"), | ||||||
|  |                             field_id=custom_field_id, | ||||||
|  |                         ).values("value_float")[:1], | ||||||
|  |                     ) | ||||||
|  |                 case CustomField.FieldDataType.DATE: | ||||||
|  |                     annotation = Subquery( | ||||||
|  |                         CustomFieldInstance.objects.filter( | ||||||
|  |                             document_id=OuterRef("id"), | ||||||
|  |                             field_id=custom_field_id, | ||||||
|  |                         ).values("value_date")[:1], | ||||||
|  |                     ) | ||||||
|  |                 case CustomField.FieldDataType.MONETARY: | ||||||
|  |                     annotation = Subquery( | ||||||
|  |                         CustomFieldInstance.objects.filter( | ||||||
|  |                             document_id=OuterRef("id"), | ||||||
|  |                             field_id=custom_field_id, | ||||||
|  |                         ).values("value_monetary_amount")[:1], | ||||||
|  |                     ) | ||||||
|  |                 case CustomField.FieldDataType.SELECT: | ||||||
|  |                     # Select options are a little more complicated since the value is the id of the option, not | ||||||
|  |                     # the label. Additionally, to support sqlite we can't use StringAgg, so we need to create a | ||||||
|  |                     # case statement for each option, setting the value to the index of the option in a list | ||||||
|  |                     # sorted by label, and then summing the results to give a single value for the annotation | ||||||
|  | 
 | ||||||
|  |                     select_options = sorted( | ||||||
|  |                         field.extra_data.get("select_options", []), | ||||||
|  |                         key=lambda x: x.get("label"), | ||||||
|  |                     ) | ||||||
|  |                     whens = [ | ||||||
|  |                         When( | ||||||
|  |                             custom_fields__field_id=custom_field_id, | ||||||
|  |                             custom_fields__value_select=option.get("id"), | ||||||
|  |                             then=Value(idx, output_field=IntegerField()), | ||||||
|  |                         ) | ||||||
|  |                         for idx, option in enumerate(select_options) | ||||||
|  |                     ] | ||||||
|  |                     whens.append( | ||||||
|  |                         When( | ||||||
|  |                             custom_fields__field_id=custom_field_id, | ||||||
|  |                             custom_fields__value_select__isnull=True, | ||||||
|  |                             then=Value( | ||||||
|  |                                 len(select_options), | ||||||
|  |                                 output_field=IntegerField(), | ||||||
|  |                             ), | ||||||
|  |                         ), | ||||||
|  |                     ) | ||||||
|  |                     annotation = Sum( | ||||||
|  |                         Case( | ||||||
|  |                             *whens, | ||||||
|  |                             default=Value(0), | ||||||
|  |                             output_field=IntegerField(), | ||||||
|  |                         ), | ||||||
|  |                     ) | ||||||
|  |                 case CustomField.FieldDataType.DOCUMENTLINK: | ||||||
|  |                     annotation = Subquery( | ||||||
|  |                         CustomFieldInstance.objects.filter( | ||||||
|  |                             document_id=OuterRef("id"), | ||||||
|  |                             field_id=custom_field_id, | ||||||
|  |                         ).values("value_document_ids")[:1], | ||||||
|  |                     ) | ||||||
|  |                 case CustomField.FieldDataType.URL: | ||||||
|  |                     annotation = Subquery( | ||||||
|  |                         CustomFieldInstance.objects.filter( | ||||||
|  |                             document_id=OuterRef("id"), | ||||||
|  |                             field_id=custom_field_id, | ||||||
|  |                         ).values("value_url")[:1], | ||||||
|  |                     ) | ||||||
|  |                 case CustomField.FieldDataType.BOOL: | ||||||
|  |                     annotation = Subquery( | ||||||
|  |                         CustomFieldInstance.objects.filter( | ||||||
|  |                             document_id=OuterRef("id"), | ||||||
|  |                             field_id=custom_field_id, | ||||||
|  |                         ).values("value_bool")[:1], | ||||||
|  |                     ) | ||||||
|  | 
 | ||||||
|  |             if not annotation: | ||||||
|  |                 # Only happens if a new data type is added and not handled here | ||||||
|  |                 raise ValueError("Invalid custom field data type") | ||||||
|  | 
 | ||||||
|  |             queryset = ( | ||||||
|  |                 queryset.annotate( | ||||||
|  |                     # We need to annotate the queryset with the custom field value | ||||||
|  |                     custom_field_value=annotation, | ||||||
|  |                     # We also need to annotate the queryset with a boolean for sorting whether the field exists | ||||||
|  |                     has_field=Exists( | ||||||
|  |                         CustomFieldInstance.objects.filter( | ||||||
|  |                             document_id=OuterRef("id"), | ||||||
|  |                             field_id=custom_field_id, | ||||||
|  |                         ), | ||||||
|  |                     ), | ||||||
|  |                 ) | ||||||
|  |                 .order_by( | ||||||
|  |                     "-has_field", | ||||||
|  |                     param.replace( | ||||||
|  |                         self.prefix + str(custom_field_id), | ||||||
|  |                         "custom_field_value", | ||||||
|  |                     ), | ||||||
|  |                 ) | ||||||
|  |                 .distinct() | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |         return super().filter_queryset(request, queryset, view) | ||||||
|  | |||||||
| @ -5,6 +5,7 @@ import tempfile | |||||||
| import uuid | import uuid | ||||||
| import zoneinfo | import zoneinfo | ||||||
| from binascii import hexlify | from binascii import hexlify | ||||||
|  | from datetime import date | ||||||
| from datetime import timedelta | from datetime import timedelta | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
| from unittest import mock | from unittest import mock | ||||||
| @ -2762,3 +2763,184 @@ class TestDocumentApiV2(DirectoriesMixin, APITestCase): | |||||||
|             self.client.get(f"/api/tags/{t.id}/", format="json").data["text_color"], |             self.client.get(f"/api/tags/{t.id}/", format="json").data["text_color"], | ||||||
|             "#000000", |             "#000000", | ||||||
|         ) |         ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class TestDocumentApiCustomFieldsSorting(DirectoriesMixin, APITestCase): | ||||||
|  |     def setUp(self): | ||||||
|  |         super().setUp() | ||||||
|  | 
 | ||||||
|  |         self.user = User.objects.create_superuser(username="temp_admin") | ||||||
|  |         self.client.force_authenticate(user=self.user) | ||||||
|  | 
 | ||||||
|  |         self.doc1 = Document.objects.create( | ||||||
|  |             title="none1", | ||||||
|  |             checksum="A", | ||||||
|  |             mime_type="application/pdf", | ||||||
|  |         ) | ||||||
|  |         self.doc2 = Document.objects.create( | ||||||
|  |             title="none2", | ||||||
|  |             checksum="B", | ||||||
|  |             mime_type="application/pdf", | ||||||
|  |         ) | ||||||
|  |         self.doc3 = Document.objects.create( | ||||||
|  |             title="none3", | ||||||
|  |             checksum="C", | ||||||
|  |             mime_type="application/pdf", | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         cache.clear() | ||||||
|  | 
 | ||||||
|  |     def test_document_custom_fields_sorting(self): | ||||||
|  |         """ | ||||||
|  |         GIVEN: | ||||||
|  |             - Documents with custom fields | ||||||
|  |         WHEN: | ||||||
|  |             - API request for document filtering with custom field sorting | ||||||
|  |         THEN: | ||||||
|  |             - Documents are sorted by custom field values | ||||||
|  |         """ | ||||||
|  |         values = { | ||||||
|  |             CustomField.FieldDataType.STRING: { | ||||||
|  |                 "values": ["foo", "bar", "baz"], | ||||||
|  |                 "field_name": CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[ | ||||||
|  |                     CustomField.FieldDataType.STRING | ||||||
|  |                 ], | ||||||
|  |             }, | ||||||
|  |             CustomField.FieldDataType.INT: { | ||||||
|  |                 "values": [3, 1, 2], | ||||||
|  |                 "field_name": CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[ | ||||||
|  |                     CustomField.FieldDataType.INT | ||||||
|  |                 ], | ||||||
|  |             }, | ||||||
|  |             CustomField.FieldDataType.FLOAT: { | ||||||
|  |                 "values": [3.3, 1.1, 2.2], | ||||||
|  |                 "field_name": CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[ | ||||||
|  |                     CustomField.FieldDataType.FLOAT | ||||||
|  |                 ], | ||||||
|  |             }, | ||||||
|  |             CustomField.FieldDataType.BOOL: { | ||||||
|  |                 "values": [True, False, False], | ||||||
|  |                 "field_name": CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[ | ||||||
|  |                     CustomField.FieldDataType.BOOL | ||||||
|  |                 ], | ||||||
|  |             }, | ||||||
|  |             CustomField.FieldDataType.DATE: { | ||||||
|  |                 "values": [date(2021, 1, 3), date(2021, 1, 1), date(2021, 1, 2)], | ||||||
|  |                 "field_name": CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[ | ||||||
|  |                     CustomField.FieldDataType.DATE | ||||||
|  |                 ], | ||||||
|  |             }, | ||||||
|  |             CustomField.FieldDataType.URL: { | ||||||
|  |                 "values": [ | ||||||
|  |                     "http://example.org", | ||||||
|  |                     "http://example.com", | ||||||
|  |                     "http://example.net", | ||||||
|  |                 ], | ||||||
|  |                 "field_name": CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[ | ||||||
|  |                     CustomField.FieldDataType.URL | ||||||
|  |                 ], | ||||||
|  |             }, | ||||||
|  |             CustomField.FieldDataType.MONETARY: { | ||||||
|  |                 "values": ["USD789.00", "USD123.00", "USD456.00"], | ||||||
|  |                 "field_name": CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[ | ||||||
|  |                     CustomField.FieldDataType.MONETARY | ||||||
|  |                 ], | ||||||
|  |             }, | ||||||
|  |             CustomField.FieldDataType.DOCUMENTLINK: { | ||||||
|  |                 "values": [self.doc3.pk, self.doc1.pk, self.doc2.pk], | ||||||
|  |                 "field_name": CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[ | ||||||
|  |                     CustomField.FieldDataType.DOCUMENTLINK | ||||||
|  |                 ], | ||||||
|  |             }, | ||||||
|  |             CustomField.FieldDataType.SELECT: { | ||||||
|  |                 "values": ["ghi-789", "abc-123", "def-456"], | ||||||
|  |                 "field_name": CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[ | ||||||
|  |                     CustomField.FieldDataType.SELECT | ||||||
|  |                 ], | ||||||
|  |                 "extra_data": { | ||||||
|  |                     "select_options": [ | ||||||
|  |                         {"label": "Option 1", "id": "abc-123"}, | ||||||
|  |                         {"label": "Option 2", "id": "def-456"}, | ||||||
|  |                         {"label": "Option 3", "id": "ghi-789"}, | ||||||
|  |                     ], | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         for data_type, data in values.items(): | ||||||
|  |             CustomField.objects.all().delete() | ||||||
|  |             CustomFieldInstance.objects.all().delete() | ||||||
|  |             custom_field = CustomField.objects.create( | ||||||
|  |                 name=f"custom field {data_type}", | ||||||
|  |                 data_type=data_type, | ||||||
|  |                 extra_data=data.get("extra_data", {}), | ||||||
|  |             ) | ||||||
|  |             for i, value in enumerate(data["values"]): | ||||||
|  |                 CustomFieldInstance.objects.create( | ||||||
|  |                     document=[self.doc1, self.doc2, self.doc3][i], | ||||||
|  |                     field=custom_field, | ||||||
|  |                     **{data["field_name"]: value}, | ||||||
|  |                 ) | ||||||
|  |             response = self.client.get( | ||||||
|  |                 f"/api/documents/?ordering=custom_field_{custom_field.pk}", | ||||||
|  |             ) | ||||||
|  |             self.assertEqual(response.status_code, status.HTTP_200_OK) | ||||||
|  |             results = response.data["results"] | ||||||
|  |             self.assertEqual(len(results), 3) | ||||||
|  |             self.assertEqual( | ||||||
|  |                 [results[0]["id"], results[1]["id"], results[2]["id"]], | ||||||
|  |                 [self.doc2.id, self.doc3.id, self.doc1.id], | ||||||
|  |             ) | ||||||
|  | 
 | ||||||
|  |             response = self.client.get( | ||||||
|  |                 f"/api/documents/?ordering=-custom_field_{custom_field.pk}", | ||||||
|  |             ) | ||||||
|  |             self.assertEqual(response.status_code, status.HTTP_200_OK) | ||||||
|  |             results = response.data["results"] | ||||||
|  |             self.assertEqual(len(results), 3) | ||||||
|  |             if data_type == CustomField.FieldDataType.BOOL: | ||||||
|  |                 # just check the first one for bools, as the rest are the same | ||||||
|  |                 self.assertEqual( | ||||||
|  |                     [results[0]["id"]], | ||||||
|  |                     [self.doc1.id], | ||||||
|  |                 ) | ||||||
|  |             else: | ||||||
|  |                 self.assertEqual( | ||||||
|  |                     [results[0]["id"], results[1]["id"], results[2]["id"]], | ||||||
|  |                     [self.doc1.id, self.doc3.id, self.doc2.id], | ||||||
|  |                 ) | ||||||
|  | 
 | ||||||
|  |     def test_document_custom_fields_sorting_invalid(self): | ||||||
|  |         """ | ||||||
|  |         GIVEN: | ||||||
|  |             - Documents with custom fields | ||||||
|  |         WHEN: | ||||||
|  |             - API request for document filtering with invalid custom field sorting | ||||||
|  |         THEN: | ||||||
|  |             - 400 is returned | ||||||
|  |         """ | ||||||
|  | 
 | ||||||
|  |         response = self.client.get( | ||||||
|  |             "/api/documents/?ordering=custom_field_999", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) | ||||||
|  | 
 | ||||||
|  |     def test_document_custom_fields_sorting_invalid_data_type(self): | ||||||
|  |         """ | ||||||
|  |         GIVEN: | ||||||
|  |             - Documents with custom fields | ||||||
|  |         WHEN: | ||||||
|  |             - API request for document filtering with a custom field sorting with a new (unhandled) data type | ||||||
|  |         THEN: | ||||||
|  |             - Error is raised | ||||||
|  |         """ | ||||||
|  | 
 | ||||||
|  |         custom_field = CustomField.objects.create( | ||||||
|  |             name="custom field", | ||||||
|  |             data_type="foo", | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         with self.assertRaises(ValueError): | ||||||
|  |             self.client.get( | ||||||
|  |                 f"/api/documents/?ordering=custom_field_{custom_field.pk}", | ||||||
|  |             ) | ||||||
|  | |||||||
| @ -96,6 +96,7 @@ from documents.data_models import DocumentSource | |||||||
| from documents.filters import CorrespondentFilterSet | from documents.filters import CorrespondentFilterSet | ||||||
| from documents.filters import CustomFieldFilterSet | from documents.filters import CustomFieldFilterSet | ||||||
| from documents.filters import DocumentFilterSet | from documents.filters import DocumentFilterSet | ||||||
|  | from documents.filters import DocumentsOrderingFilter | ||||||
| from documents.filters import DocumentTypeFilterSet | from documents.filters import DocumentTypeFilterSet | ||||||
| from documents.filters import ObjectOwnedOrGrantedPermissionsFilter | from documents.filters import ObjectOwnedOrGrantedPermissionsFilter | ||||||
| from documents.filters import ObjectOwnedPermissionsFilter | from documents.filters import ObjectOwnedPermissionsFilter | ||||||
| @ -350,7 +351,7 @@ class DocumentViewSet( | |||||||
|     filter_backends = ( |     filter_backends = ( | ||||||
|         DjangoFilterBackend, |         DjangoFilterBackend, | ||||||
|         SearchFilter, |         SearchFilter, | ||||||
|         OrderingFilter, |         DocumentsOrderingFilter, | ||||||
|         ObjectOwnedOrGrantedPermissionsFilter, |         ObjectOwnedOrGrantedPermissionsFilter, | ||||||
|     ) |     ) | ||||||
|     filterset_class = DocumentFilterSet |     filterset_class = DocumentFilterSet | ||||||
| @ -367,6 +368,7 @@ class DocumentViewSet( | |||||||
|         "num_notes", |         "num_notes", | ||||||
|         "owner", |         "owner", | ||||||
|         "page_count", |         "page_count", | ||||||
|  |         "custom_field_", | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|     def get_queryset(self): |     def get_queryset(self): | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user