mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-04-02 13:45:10 -05:00
Enhancement: only include correspondent 'last_correspondence' if requested (#6792)
This commit is contained in:
parent
c0c44b512c
commit
8abb0cd75d
@ -109,13 +109,13 @@
|
|||||||
<pngx-input-number i18n-title title="Archive serial number" [error]="error?.archive_serial_number" [horizontal]="true" formControlName='archive_serial_number'></pngx-input-number>
|
<pngx-input-number i18n-title title="Archive serial number" [error]="error?.archive_serial_number" [horizontal]="true" formControlName='archive_serial_number'></pngx-input-number>
|
||||||
<pngx-input-date i18n-title title="Date created" formControlName="created_date" [suggestions]="suggestions?.dates" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
|
<pngx-input-date i18n-title title="Date created" formControlName="created_date" [suggestions]="suggestions?.dates" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
|
||||||
[error]="error?.created_date"></pngx-input-date>
|
[error]="error?.created_date"></pngx-input-date>
|
||||||
<pngx-input-select [items]="correspondents" i18n-title title="Correspondent" formControlName="correspondent" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
|
<pngx-input-select [items]="correspondents" i18n-title title="Correspondent" formControlName="correspondent" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.Correspondent)"
|
||||||
(createNew)="createCorrespondent($event)" [suggestions]="suggestions?.correspondents" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }"></pngx-input-select>
|
(createNew)="createCorrespondent($event)" [suggestions]="suggestions?.correspondents" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }"></pngx-input-select>
|
||||||
<pngx-input-select [items]="documentTypes" i18n-title title="Document type" formControlName="document_type" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
|
<pngx-input-select [items]="documentTypes" i18n-title title="Document type" formControlName="document_type" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.DocumentType)"
|
||||||
(createNew)="createDocumentType($event)" [suggestions]="suggestions?.document_types" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }"></pngx-input-select>
|
(createNew)="createDocumentType($event)" [suggestions]="suggestions?.document_types" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }"></pngx-input-select>
|
||||||
<pngx-input-select [items]="storagePaths" i18n-title title="Storage path" formControlName="storage_path" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
|
<pngx-input-select [items]="storagePaths" i18n-title title="Storage path" formControlName="storage_path" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.StoragePath)"
|
||||||
(createNew)="createStoragePath($event)" [suggestions]="suggestions?.storage_paths" i18n-placeholder placeholder="Default" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }"></pngx-input-select>
|
(createNew)="createStoragePath($event)" [suggestions]="suggestions?.storage_paths" i18n-placeholder placeholder="Default" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }"></pngx-input-select>
|
||||||
<pngx-input-tags formControlName="tags" [suggestions]="suggestions?.tags" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"></pngx-input-tags>
|
<pngx-input-tags formControlName="tags" [suggestions]="suggestions?.tags" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.Tag)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"></pngx-input-tags>
|
||||||
@for (fieldInstance of document?.custom_fields; track fieldInstance.field; let i = $index) {
|
@for (fieldInstance of document?.custom_fields; track fieldInstance.field; let i = $index) {
|
||||||
<div [formGroup]="customFieldFormFields.controls[i]">
|
<div [formGroup]="customFieldFormFields.controls[i]">
|
||||||
@switch (getCustomFieldFromInstance(fieldInstance)?.data_type) {
|
@switch (getCustomFieldFromInstance(fieldInstance)?.data_type) {
|
||||||
|
@ -80,8 +80,9 @@ import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
|||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { RotateConfirmDialogComponent } from '../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
import { RotateConfirmDialogComponent } from '../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
||||||
import { SplitConfirmDialogComponent } from '../common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
|
import { SplitConfirmDialogComponent } from '../common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
|
||||||
import { PdfViewerModule } from 'ng2-pdf-viewer'
|
|
||||||
import { DeletePagesConfirmDialogComponent } from '../common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component'
|
import { DeletePagesConfirmDialogComponent } from '../common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component'
|
||||||
|
import { PdfViewerModule } from 'ng2-pdf-viewer'
|
||||||
|
import { DataType } from 'src/app/data/datatype'
|
||||||
|
|
||||||
const doc: Document = {
|
const doc: Document = {
|
||||||
id: 3,
|
id: 3,
|
||||||
@ -783,10 +784,9 @@ describe('DocumentDetailComponent', () => {
|
|||||||
const object = {
|
const object = {
|
||||||
id: 22,
|
id: 22,
|
||||||
name: 'Correspondent22',
|
name: 'Correspondent22',
|
||||||
last_correspondence: new Date().toISOString(),
|
|
||||||
} as Correspondent
|
} as Correspondent
|
||||||
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
|
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
|
||||||
component.filterDocuments([object])
|
component.filterDocuments([object], DataType.Correspondent)
|
||||||
expect(qfSpy).toHaveBeenCalledWith([
|
expect(qfSpy).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
rule_type: FILTER_CORRESPONDENT,
|
rule_type: FILTER_CORRESPONDENT,
|
||||||
@ -799,7 +799,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
initNormally()
|
initNormally()
|
||||||
const object = { id: 22, name: 'DocumentType22' } as DocumentType
|
const object = { id: 22, name: 'DocumentType22' } as DocumentType
|
||||||
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
|
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
|
||||||
component.filterDocuments([object])
|
component.filterDocuments([object], DataType.DocumentType)
|
||||||
expect(qfSpy).toHaveBeenCalledWith([
|
expect(qfSpy).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
rule_type: FILTER_DOCUMENT_TYPE,
|
rule_type: FILTER_DOCUMENT_TYPE,
|
||||||
@ -816,7 +816,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
path: '/foo/bar/',
|
path: '/foo/bar/',
|
||||||
} as StoragePath
|
} as StoragePath
|
||||||
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
|
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
|
||||||
component.filterDocuments([object])
|
component.filterDocuments([object], DataType.StoragePath)
|
||||||
expect(qfSpy).toHaveBeenCalledWith([
|
expect(qfSpy).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
rule_type: FILTER_STORAGE_PATH,
|
rule_type: FILTER_STORAGE_PATH,
|
||||||
@ -842,7 +842,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
text_color: '#000000',
|
text_color: '#000000',
|
||||||
} as Tag
|
} as Tag
|
||||||
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
|
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
|
||||||
component.filterDocuments([object1, object2])
|
component.filterDocuments([object1, object2], DataType.Tag)
|
||||||
expect(qfSpy).toHaveBeenCalledWith([
|
expect(qfSpy).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
rule_type: FILTER_HAS_TAGS_ALL,
|
rule_type: FILTER_HAS_TAGS_ALL,
|
||||||
|
@ -71,6 +71,7 @@ import { RotateConfirmDialogComponent } from '../common/confirm-dialog/rotate-co
|
|||||||
import { DeletePagesConfirmDialogComponent } from '../common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component'
|
import { DeletePagesConfirmDialogComponent } from '../common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component'
|
||||||
import { HotKeyService } from 'src/app/services/hot-key.service'
|
import { HotKeyService } from 'src/app/services/hot-key.service'
|
||||||
import { PDFDocumentProxy } from 'ng2-pdf-viewer'
|
import { PDFDocumentProxy } from 'ng2-pdf-viewer'
|
||||||
|
import { DataType } from 'src/app/data/datatype'
|
||||||
|
|
||||||
enum DocumentDetailNavIDs {
|
enum DocumentDetailNavIDs {
|
||||||
Details = 1,
|
Details = 1,
|
||||||
@ -171,6 +172,8 @@ export class DocumentDetailComponent
|
|||||||
|
|
||||||
public readonly ContentRenderType = ContentRenderType
|
public readonly ContentRenderType = ContentRenderType
|
||||||
|
|
||||||
|
public readonly DataType = DataType
|
||||||
|
|
||||||
@ViewChild('nav') nav: NgbNav
|
@ViewChild('nav') nav: NgbNav
|
||||||
@ViewChild('pdfPreview') set pdfPreview(element) {
|
@ViewChild('pdfPreview') set pdfPreview(element) {
|
||||||
// this gets called when component added or removed from DOM
|
// this gets called when component added or removed from DOM
|
||||||
@ -998,7 +1001,7 @@ export class DocumentDetailComponent
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
filterDocuments(items: ObjectWithId[] | NgbDateStruct[]) {
|
filterDocuments(items: ObjectWithId[] | NgbDateStruct[], type?: DataType) {
|
||||||
const filterRules: FilterRule[] = items.flatMap((i) => {
|
const filterRules: FilterRule[] = items.flatMap((i) => {
|
||||||
if (i.hasOwnProperty('year')) {
|
if (i.hasOwnProperty('year')) {
|
||||||
const isoDateAdapter = new ISODateAdapter()
|
const isoDateAdapter = new ISODateAdapter()
|
||||||
@ -1017,30 +1020,28 @@ export class DocumentDetailComponent
|
|||||||
value: dateBefore.toISOString().substring(0, 10),
|
value: dateBefore.toISOString().substring(0, 10),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
} else if (i.hasOwnProperty('last_correspondence')) {
|
}
|
||||||
// Correspondent
|
switch (type) {
|
||||||
return {
|
case DataType.Correspondent:
|
||||||
rule_type: FILTER_CORRESPONDENT,
|
return {
|
||||||
value: (i as Correspondent).id.toString(),
|
rule_type: FILTER_CORRESPONDENT,
|
||||||
}
|
value: (i as Correspondent).id.toString(),
|
||||||
} else if (i.hasOwnProperty('path')) {
|
}
|
||||||
// Storage Path
|
case DataType.DocumentType:
|
||||||
return {
|
return {
|
||||||
rule_type: FILTER_STORAGE_PATH,
|
rule_type: FILTER_DOCUMENT_TYPE,
|
||||||
value: (i as StoragePath).id.toString(),
|
value: (i as DocumentType).id.toString(),
|
||||||
}
|
}
|
||||||
} else if (i.hasOwnProperty('is_inbox_tag')) {
|
case DataType.StoragePath:
|
||||||
// Tag
|
return {
|
||||||
return {
|
rule_type: FILTER_STORAGE_PATH,
|
||||||
rule_type: FILTER_HAS_TAGS_ALL,
|
value: (i as StoragePath).id.toString(),
|
||||||
value: (i as Tag).id.toString(),
|
}
|
||||||
}
|
case DataType.Tag:
|
||||||
} else {
|
return {
|
||||||
// Document Type, has no specific props
|
rule_type: FILTER_HAS_TAGS_ALL,
|
||||||
return {
|
value: (i as Tag).id.toString(),
|
||||||
rule_type: FILTER_DOCUMENT_TYPE,
|
}
|
||||||
value: (i as DocumentType).id.toString(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -12,6 +12,7 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic
|
|||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||||
import { ManagementListComponent } from '../management-list/management-list.component'
|
import { ManagementListComponent } from '../management-list/management-list.component'
|
||||||
|
import { takeUntil } from 'rxjs'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-correspondent-list',
|
selector: 'pngx-correspondent-list',
|
||||||
@ -63,6 +64,26 @@ export class CorrespondentListComponent extends ManagementListComponent<Correspo
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public reloadData(): void {
|
||||||
|
this.isLoading = true
|
||||||
|
this.service
|
||||||
|
.listFiltered(
|
||||||
|
this.page,
|
||||||
|
null,
|
||||||
|
this.sortField,
|
||||||
|
this.sortReverse,
|
||||||
|
this._nameFilter,
|
||||||
|
true,
|
||||||
|
{ last_correspondence: true }
|
||||||
|
)
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe((c) => {
|
||||||
|
this.data = c.results
|
||||||
|
this.collectionSize = c.count
|
||||||
|
this.isLoading = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
getDeleteMessage(object: Correspondent) {
|
getDeleteMessage(object: Correspondent) {
|
||||||
return $localize`Do you really want to delete the correspondent "${object.name}"?`
|
return $localize`Do you really want to delete the correspondent "${object.name}"?`
|
||||||
}
|
}
|
||||||
|
@ -52,7 +52,7 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
|
|||||||
implements OnInit, OnDestroy
|
implements OnInit, OnDestroy
|
||||||
{
|
{
|
||||||
constructor(
|
constructor(
|
||||||
private service: AbstractNameFilterService<T>,
|
protected service: AbstractNameFilterService<T>,
|
||||||
private modalService: NgbModal,
|
private modalService: NgbModal,
|
||||||
private editDialogComponent: any,
|
private editDialogComponent: any,
|
||||||
private toastService: ToastService,
|
private toastService: ToastService,
|
||||||
@ -81,8 +81,8 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
|
|||||||
public isLoading: boolean = false
|
public isLoading: boolean = false
|
||||||
|
|
||||||
private nameFilterDebounce: Subject<string>
|
private nameFilterDebounce: Subject<string>
|
||||||
private unsubscribeNotifier: Subject<any> = new Subject()
|
protected unsubscribeNotifier: Subject<any> = new Subject()
|
||||||
private _nameFilter: string
|
protected _nameFilter: string
|
||||||
|
|
||||||
public selectedObjects: Set<number> = new Set()
|
public selectedObjects: Set<number> = new Set()
|
||||||
public togggleAll: boolean = false
|
public togggleAll: boolean = false
|
||||||
|
@ -17,9 +17,10 @@ export abstract class AbstractNameFilterService<
|
|||||||
sortField?: string,
|
sortField?: string,
|
||||||
sortReverse?: boolean,
|
sortReverse?: boolean,
|
||||||
nameFilter?: string,
|
nameFilter?: string,
|
||||||
fullPerms?: boolean
|
fullPerms?: boolean,
|
||||||
|
extraParams?: { [key: string]: any }
|
||||||
) {
|
) {
|
||||||
let params = {}
|
let params = extraParams ?? {}
|
||||||
if (nameFilter) {
|
if (nameFilter) {
|
||||||
params['name__icontains'] = nameFilter
|
params['name__icontains'] = nameFilter
|
||||||
}
|
}
|
||||||
|
@ -291,7 +291,7 @@ class OwnedObjectSerializer(
|
|||||||
|
|
||||||
|
|
||||||
class CorrespondentSerializer(MatchingModelSerializer, OwnedObjectSerializer):
|
class CorrespondentSerializer(MatchingModelSerializer, OwnedObjectSerializer):
|
||||||
last_correspondence = serializers.DateTimeField(read_only=True)
|
last_correspondence = serializers.DateTimeField(read_only=True, required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Correspondent
|
model = Correspondent
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
|
import datetime
|
||||||
import json
|
import json
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.utils import timezone
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
@ -89,6 +91,57 @@ class TestApiObjects(DirectoriesMixin, APITestCase):
|
|||||||
results = response.data["results"]
|
results = response.data["results"]
|
||||||
self.assertEqual(len(results), 2)
|
self.assertEqual(len(results), 2)
|
||||||
|
|
||||||
|
def test_correspondent_last_correspondence(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Correspondent with documents
|
||||||
|
WHEN:
|
||||||
|
- API is called
|
||||||
|
THEN:
|
||||||
|
- Last correspondence date is returned only if requested for list, and for detail
|
||||||
|
"""
|
||||||
|
|
||||||
|
Document.objects.create(
|
||||||
|
mime_type="application/pdf",
|
||||||
|
correspondent=self.c1,
|
||||||
|
created=timezone.make_aware(datetime.datetime(2022, 1, 1)),
|
||||||
|
checksum="123",
|
||||||
|
)
|
||||||
|
Document.objects.create(
|
||||||
|
mime_type="application/pdf",
|
||||||
|
correspondent=self.c1,
|
||||||
|
created=timezone.make_aware(datetime.datetime(2022, 1, 2)),
|
||||||
|
checksum="456",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only if requested for list
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/correspondents/",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
results = response.data["results"]
|
||||||
|
self.assertNotIn("last_correspondence", results[0])
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/correspondents/?last_correspondence=true",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
results = response.data["results"]
|
||||||
|
self.assertIn(
|
||||||
|
"2022-01-02",
|
||||||
|
results[0]["last_correspondence"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Included in detail by default
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/correspondents/{self.c1.id}/",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertIn(
|
||||||
|
"2022-01-02",
|
||||||
|
response.data["last_correspondence"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
class TestApiStoragePaths(DirectoriesMixin, APITestCase):
|
||||||
ENDPOINT = "/api/storage_paths/"
|
ENDPOINT = "/api/storage_paths/"
|
||||||
|
@ -253,14 +253,7 @@ class PermissionsAwareDocumentCountMixin(PassUserMixin):
|
|||||||
class CorrespondentViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
|
class CorrespondentViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
|
||||||
model = Correspondent
|
model = Correspondent
|
||||||
|
|
||||||
queryset = (
|
queryset = Correspondent.objects.select_related("owner").order_by(Lower("name"))
|
||||||
Correspondent.objects.prefetch_related("documents")
|
|
||||||
.annotate(
|
|
||||||
last_correspondence=Max("documents__created"),
|
|
||||||
)
|
|
||||||
.select_related("owner")
|
|
||||||
.order_by(Lower("name"))
|
|
||||||
)
|
|
||||||
|
|
||||||
serializer_class = CorrespondentSerializer
|
serializer_class = CorrespondentSerializer
|
||||||
pagination_class = StandardPagination
|
pagination_class = StandardPagination
|
||||||
@ -279,6 +272,19 @@ class CorrespondentViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
|
|||||||
"last_correspondence",
|
"last_correspondence",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def list(self, request, *args, **kwargs):
|
||||||
|
if request.query_params.get("last_correspondence", None):
|
||||||
|
self.queryset = self.queryset.annotate(
|
||||||
|
last_correspondence=Max("documents__created"),
|
||||||
|
)
|
||||||
|
return super().list(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def retrieve(self, request, *args, **kwargs):
|
||||||
|
self.queryset = self.queryset.annotate(
|
||||||
|
last_correspondence=Max("documents__created"),
|
||||||
|
)
|
||||||
|
return super().retrieve(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class TagViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
|
class TagViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
|
||||||
model = Tag
|
model = Tag
|
||||||
|
Loading…
x
Reference in New Issue
Block a user