mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-12-09 00:05:21 +01:00
feat: Complete deletion requests management UI implementation
- Backend API: - Added DeletionRequestSerializer and DeletionRequestActionSerializer - Added DeletionRequestViewSet with approve/reject/pending_count actions - Added /api/deletion_requests/ route - Frontend: - Created deletion-request data model - Created deletion-request.service.ts with full CRUD operations - Created DeletionRequestsComponent with status filtering (pending/approved/rejected/completed) - Created DeletionRequestDetailComponent with impact analysis display - Added /deletion-requests route with permissions guard - Implemented approve/reject modals with confirmation - Added status badges and pending request counter - All code linted and built successfully Co-authored-by: dawnsystem <42047891+dawnsystem@users.noreply.github.com>
This commit is contained in:
parent
1b4bc75a80
commit
5edfbfc028
15 changed files with 22738 additions and 0 deletions
21751
src-ui/package-lock.json
generated
Normal file
21751
src-ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -8,6 +8,7 @@ import { TrashComponent } from './components/admin/trash/trash.component'
|
|||
import { UsersAndGroupsComponent } from './components/admin/users-groups/users-groups.component'
|
||||
import { AppFrameComponent } from './components/app-frame/app-frame.component'
|
||||
import { DashboardComponent } from './components/dashboard/dashboard.component'
|
||||
import { DeletionRequestsComponent } from './components/deletion-requests/deletion-requests.component'
|
||||
import { DocumentAsnComponent } from './components/document-asn/document-asn.component'
|
||||
import { DocumentDetailComponent } from './components/document-detail/document-detail.component'
|
||||
import { DocumentListComponent } from './components/document-list/document-list.component'
|
||||
|
|
@ -174,6 +175,18 @@ export const routes: Routes = [
|
|||
componentName: 'TrashComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'deletion-requests',
|
||||
component: DeletionRequestsComponent,
|
||||
canActivate: [PermissionsGuard],
|
||||
data: {
|
||||
requiredPermission: {
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.Document,
|
||||
},
|
||||
componentName: 'DeletionRequestsComponent',
|
||||
},
|
||||
},
|
||||
// redirect old paths
|
||||
{
|
||||
path: 'settings/mail',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,244 @@
|
|||
<div class="modal-header">
|
||||
<h4 class="modal-title" i18n>Deletion Request Details</h4>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
aria-label="Close"
|
||||
(click)="activeModal.dismiss()"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<!-- Request Information -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0" i18n>Request Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-3"><strong i18n>ID:</strong></div>
|
||||
<div class="col-md-9">{{ deletionRequest.id }}</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-3"><strong i18n>Status:</strong></div>
|
||||
<div class="col-md-9">
|
||||
<span
|
||||
class="badge"
|
||||
[ngClass]="{
|
||||
'bg-warning text-dark': deletionRequest.status === DeletionRequestStatus.Pending,
|
||||
'bg-success': deletionRequest.status === DeletionRequestStatus.Approved,
|
||||
'bg-danger': deletionRequest.status === DeletionRequestStatus.Rejected,
|
||||
'bg-info': deletionRequest.status === DeletionRequestStatus.Completed,
|
||||
'bg-secondary': deletionRequest.status === DeletionRequestStatus.Cancelled
|
||||
}"
|
||||
>
|
||||
{{ deletionRequest.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-3"><strong i18n>Created:</strong></div>
|
||||
<div class="col-md-9">
|
||||
{{ deletionRequest.created_at | customDate:'medium' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-3"><strong i18n>User:</strong></div>
|
||||
<div class="col-md-9">{{ deletionRequest.user_username }}</div>
|
||||
</div>
|
||||
@if (deletionRequest.reviewed_by_username) {
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-3"><strong i18n>Reviewed By:</strong></div>
|
||||
<div class="col-md-9">{{ deletionRequest.reviewed_by_username }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (deletionRequest.reviewed_at) {
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-3"><strong i18n>Reviewed At:</strong></div>
|
||||
<div class="col-md-9">
|
||||
{{ deletionRequest.reviewed_at | customDate:'medium' }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI Reason -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0" i18n>AI Reason</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>{{ deletionRequest.ai_reason }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Impact Analysis -->
|
||||
@if (deletionRequest.impact_summary) {
|
||||
<div class="card mb-3">
|
||||
<div class="card-header bg-warning text-dark">
|
||||
<h5 class="mb-0" i18n>
|
||||
<i-bs name="exclamation-triangle"></i-bs> Impact Analysis
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<div class="text-center p-3 bg-light rounded">
|
||||
<h3 class="mb-0">{{ deletionRequest.impact_summary.document_count }}</h3>
|
||||
<small class="text-muted" i18n>Documents</small>
|
||||
</div>
|
||||
</div>
|
||||
@if (deletionRequest.impact_summary.affected_tags?.length > 0) {
|
||||
<div class="col-md-4">
|
||||
<div class="text-center p-3 bg-light rounded">
|
||||
<h3 class="mb-0">{{ deletionRequest.impact_summary.affected_tags.length }}</h3>
|
||||
<small class="text-muted" i18n>Affected Tags</small>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (deletionRequest.impact_summary.affected_correspondents?.length > 0) {
|
||||
<div class="col-md-4">
|
||||
<div class="text-center p-3 bg-light rounded">
|
||||
<h3 class="mb-0">{{ deletionRequest.impact_summary.affected_correspondents.length }}</h3>
|
||||
<small class="text-muted" i18n>Affected Correspondents</small>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (deletionRequest.impact_summary.affected_tags?.length > 0) {
|
||||
<div class="mb-3">
|
||||
<strong i18n>Affected Tags:</strong>
|
||||
<div class="mt-1">
|
||||
@for (tag of deletionRequest.impact_summary.affected_tags; track tag) {
|
||||
<span class="badge bg-secondary me-1">{{ tag }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (deletionRequest.impact_summary.affected_correspondents?.length > 0) {
|
||||
<div class="mb-3">
|
||||
<strong i18n>Affected Correspondents:</strong>
|
||||
<div class="mt-1">
|
||||
@for (correspondent of deletionRequest.impact_summary.affected_correspondents; track correspondent) {
|
||||
<span class="badge bg-info me-1">{{ correspondent }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (deletionRequest.impact_summary.affected_types?.length > 0) {
|
||||
<div class="mb-3">
|
||||
<strong i18n>Affected Document Types:</strong>
|
||||
<div class="mt-1">
|
||||
@for (type of deletionRequest.impact_summary.affected_types; track type) {
|
||||
<span class="badge bg-primary me-1">{{ type }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Documents List -->
|
||||
@if (deletionRequest.documents_detail && deletionRequest.documents_detail.length > 0) {
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0" i18n>Documents to be Deleted</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" i18n>ID</th>
|
||||
<th scope="col" i18n>Title</th>
|
||||
<th scope="col" i18n>Correspondent</th>
|
||||
<th scope="col" i18n>Type</th>
|
||||
<th scope="col" i18n>Tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (doc of deletionRequest.documents_detail; track doc.id) {
|
||||
<tr>
|
||||
<td>{{ doc.id }}</td>
|
||||
<td>{{ doc.title }}</td>
|
||||
<td>{{ doc.correspondent || '-' }}</td>
|
||||
<td>{{ doc.document_type || '-' }}</td>
|
||||
<td>
|
||||
@for (tag of doc.tags; track tag) {
|
||||
<span class="badge bg-secondary me-1">{{ tag }}</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Review Comment (if exists or editable) -->
|
||||
@if (deletionRequest.review_comment || canModify()) {
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0" i18n>Review Comment</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (deletionRequest.review_comment && !canModify()) {
|
||||
<p>{{ deletionRequest.review_comment }}</p>
|
||||
} @else if (canModify()) {
|
||||
<textarea
|
||||
class="form-control"
|
||||
rows="3"
|
||||
[(ngModel)]="reviewComment"
|
||||
placeholder="Add a comment (optional)"
|
||||
i18n-placeholder
|
||||
></textarea>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
@if (canModify()) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
(click)="reject()"
|
||||
[disabled]="isProcessing"
|
||||
>
|
||||
@if (isProcessing) {
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||
}
|
||||
<i-bs name="x-circle"></i-bs>
|
||||
<ng-container i18n>Reject</ng-container>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success"
|
||||
(click)="approve()"
|
||||
[disabled]="isProcessing"
|
||||
>
|
||||
@if (isProcessing) {
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||
}
|
||||
<i-bs name="check-circle"></i-bs>
|
||||
<ng-container i18n>Approve</ng-container>
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
(click)="activeModal.dismiss()"
|
||||
[disabled]="isProcessing"
|
||||
i18n
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -0,0 +1 @@
|
|||
// Detail component styles
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { DeletionRequestDetailComponent } from './deletion-request-detail.component'
|
||||
import { DeletionRequestService } from 'src/app/services/rest/deletion-request.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
|
||||
describe('DeletionRequestDetailComponent', () => {
|
||||
let component: DeletionRequestDetailComponent
|
||||
let fixture: ComponentFixture<DeletionRequestDetailComponent>
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DeletionRequestDetailComponent, HttpClientTestingModule],
|
||||
providers: [NgbActiveModal, DeletionRequestService, ToastService],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(DeletionRequestDetailComponent)
|
||||
component = fixture.componentInstance
|
||||
component.deletionRequest = {
|
||||
id: 1,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
requested_by_ai: true,
|
||||
ai_reason: 'Test reason',
|
||||
user: 1,
|
||||
user_username: 'testuser',
|
||||
status: 'pending' as any,
|
||||
documents: [1, 2],
|
||||
documents_detail: [],
|
||||
document_count: 2,
|
||||
impact_summary: {
|
||||
document_count: 2,
|
||||
documents: [],
|
||||
affected_tags: [],
|
||||
affected_correspondents: [],
|
||||
affected_types: [],
|
||||
},
|
||||
}
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import { CommonModule } from '@angular/common'
|
||||
import { Component, inject, Input } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import {
|
||||
DeletionRequest,
|
||||
DeletionRequestStatus,
|
||||
} from 'src/app/data/deletion-request'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { DeletionRequestService } from 'src/app/services/rest/deletion-request.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-deletion-request-detail',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgxBootstrapIconsModule,
|
||||
CustomDatePipe,
|
||||
],
|
||||
templateUrl: './deletion-request-detail.component.html',
|
||||
styleUrls: ['./deletion-request-detail.component.scss'],
|
||||
})
|
||||
export class DeletionRequestDetailComponent {
|
||||
@Input() deletionRequest: DeletionRequest
|
||||
|
||||
public DeletionRequestStatus = DeletionRequestStatus
|
||||
public activeModal = inject(NgbActiveModal)
|
||||
private deletionRequestService = inject(DeletionRequestService)
|
||||
private toastService = inject(ToastService)
|
||||
|
||||
public reviewComment: string = ''
|
||||
public isProcessing: boolean = false
|
||||
|
||||
approve(): void {
|
||||
if (this.isProcessing) return
|
||||
|
||||
this.isProcessing = true
|
||||
this.deletionRequestService
|
||||
.approve(this.deletionRequest.id, this.reviewComment)
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.toastService.showInfo(
|
||||
$localize`Deletion request approved successfully`
|
||||
)
|
||||
this.isProcessing = false
|
||||
this.activeModal.close('approved')
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error approving deletion request`,
|
||||
error
|
||||
)
|
||||
this.isProcessing = false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
reject(): void {
|
||||
if (this.isProcessing) return
|
||||
|
||||
this.isProcessing = true
|
||||
this.deletionRequestService
|
||||
.reject(this.deletionRequest.id, this.reviewComment)
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.toastService.showInfo(
|
||||
$localize`Deletion request rejected successfully`
|
||||
)
|
||||
this.isProcessing = false
|
||||
this.activeModal.close('rejected')
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error rejecting deletion request`,
|
||||
error
|
||||
)
|
||||
this.isProcessing = false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
canModify(): boolean {
|
||||
return this.deletionRequest.status === DeletionRequestStatus.Pending
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
<pngx-page-header
|
||||
title="Deletion Requests"
|
||||
i18n-title
|
||||
info="Manage AI-initiated deletion requests. Review and approve or reject document deletions recommended by the AI system."
|
||||
i18n-info
|
||||
>
|
||||
@if (getPendingCount() > 0) {
|
||||
<div class="badge bg-warning text-dark ms-2">
|
||||
{{ getPendingCount() }} <ng-container i18n>pending</ng-container>
|
||||
</div>
|
||||
}
|
||||
</pngx-page-header>
|
||||
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-tabs mb-3">
|
||||
<li [ngbNavItem]="DeletionRequestStatus.Pending">
|
||||
<button ngbNavLink (click)="onTabChange(DeletionRequestStatus.Pending)">
|
||||
<ng-container i18n>Pending</ng-container>
|
||||
@if (getStatusCount(DeletionRequestStatus.Pending) > 0) {
|
||||
<span class="badge bg-warning text-dark ms-1">
|
||||
{{ getStatusCount(DeletionRequestStatus.Pending) }}
|
||||
</span>
|
||||
}
|
||||
</button>
|
||||
</li>
|
||||
<li [ngbNavItem]="DeletionRequestStatus.Approved">
|
||||
<button ngbNavLink (click)="onTabChange(DeletionRequestStatus.Approved)">
|
||||
<ng-container i18n>Approved</ng-container>
|
||||
</button>
|
||||
</li>
|
||||
<li [ngbNavItem]="DeletionRequestStatus.Rejected">
|
||||
<button ngbNavLink (click)="onTabChange(DeletionRequestStatus.Rejected)">
|
||||
<ng-container i18n>Rejected</ng-container>
|
||||
</button>
|
||||
</li>
|
||||
<li [ngbNavItem]="DeletionRequestStatus.Completed">
|
||||
<button ngbNavLink (click)="onTabChange(DeletionRequestStatus.Completed)">
|
||||
<ng-container i18n>Completed</ng-container>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div [ngbNavOutlet]="nav"></div>
|
||||
|
||||
@if (deletionRequestService.loading) {
|
||||
<div class="text-center my-5">
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden" i18n>Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
} @else if (filteredRequests.length === 0) {
|
||||
<div class="alert alert-info" i18n>
|
||||
No deletion requests found with status: {{ activeTab }}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" i18n>ID</th>
|
||||
<th scope="col" i18n>Created</th>
|
||||
<th scope="col" i18n>Documents</th>
|
||||
<th scope="col" i18n>AI Reason</th>
|
||||
<th scope="col" i18n>Status</th>
|
||||
<th scope="col" i18n>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (request of filteredRequests | slice: (page-1) * pageSize : page * pageSize; track request.id) {
|
||||
<tr (click)="viewDetails(request)" style="cursor: pointer;">
|
||||
<td>{{ request.id }}</td>
|
||||
<td>{{ request.created_at | customDate:'short' }}</td>
|
||||
<td>
|
||||
<span class="badge bg-primary">
|
||||
{{ request.document_count }} <ng-container i18n>docs</ng-container>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-truncate" style="max-width: 300px;" [ngbTooltip]="request.ai_reason">
|
||||
{{ request.ai_reason }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" [ngClass]="getStatusBadgeClass(request.status)">
|
||||
{{ request.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
(click)="viewDetails(request); $event.stopPropagation()"
|
||||
i18n
|
||||
>
|
||||
<i-bs name="eye"></i-bs> View Details
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if (collectionSize > pageSize) {
|
||||
<div class="d-flex justify-content-center mt-3">
|
||||
<ngb-pagination
|
||||
[(page)]="page"
|
||||
[pageSize]="pageSize"
|
||||
[collectionSize]="collectionSize"
|
||||
[maxSize]="5"
|
||||
[rotate]="true"
|
||||
[boundaryLinks]="true"
|
||||
></ngb-pagination>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// Component-specific styles for deletion requests
|
||||
.text-truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||
import { DeletionRequestsComponent } from './deletion-requests.component'
|
||||
import { DeletionRequestService } from 'src/app/services/rest/deletion-request.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
|
||||
describe('DeletionRequestsComponent', () => {
|
||||
let component: DeletionRequestsComponent
|
||||
let fixture: ComponentFixture<DeletionRequestsComponent>
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DeletionRequestsComponent, HttpClientTestingModule],
|
||||
providers: [DeletionRequestService, ToastService],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(DeletionRequestsComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
import { CommonModule } from '@angular/common'
|
||||
import { Component, OnInit, OnDestroy, inject } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import {
|
||||
NgbModal,
|
||||
NgbNavModule,
|
||||
NgbPaginationModule,
|
||||
NgbTooltipModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { Subject, takeUntil } from 'rxjs'
|
||||
import {
|
||||
DeletionRequest,
|
||||
DeletionRequestStatus,
|
||||
} from 'src/app/data/deletion-request'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { DeletionRequestService } from 'src/app/services/rest/deletion-request.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { PageHeaderComponent } from '../common/page-header/page-header.component'
|
||||
import { LoadingComponentWithPermissions } from '../loading-component/loading.component'
|
||||
import { DeletionRequestDetailComponent } from './deletion-request-detail/deletion-request-detail.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-deletion-requests',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgbNavModule,
|
||||
NgbPaginationModule,
|
||||
NgbTooltipModule,
|
||||
NgxBootstrapIconsModule,
|
||||
PageHeaderComponent,
|
||||
CustomDatePipe,
|
||||
],
|
||||
templateUrl: './deletion-requests.component.html',
|
||||
styleUrls: ['./deletion-requests.component.scss'],
|
||||
})
|
||||
export class DeletionRequestsComponent
|
||||
extends LoadingComponentWithPermissions
|
||||
implements OnInit, OnDestroy
|
||||
{
|
||||
public DeletionRequestStatus = DeletionRequestStatus
|
||||
|
||||
public deletionRequestService = inject(DeletionRequestService)
|
||||
private modalService = inject(NgbModal)
|
||||
private toastService = inject(ToastService)
|
||||
protected unsubscribeNotifier: Subject<void> = new Subject()
|
||||
|
||||
public deletionRequests: DeletionRequest[] = []
|
||||
public filteredRequests: DeletionRequest[] = []
|
||||
public activeTab: DeletionRequestStatus = DeletionRequestStatus.Pending
|
||||
public page: number = 1
|
||||
public pageSize: number = 25
|
||||
public collectionSize: number = 0
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadDeletionRequests()
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.unsubscribeNotifier.next()
|
||||
this.unsubscribeNotifier.complete()
|
||||
}
|
||||
|
||||
loadDeletionRequests(): void {
|
||||
this.deletionRequestService
|
||||
.listAll()
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.deletionRequests = result.results
|
||||
this.filterByStatus()
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error loading deletion requests`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
filterByStatus(): void {
|
||||
this.filteredRequests = this.deletionRequests.filter(
|
||||
(req) => req.status === this.activeTab
|
||||
)
|
||||
this.collectionSize = this.filteredRequests.length
|
||||
this.page = 1
|
||||
}
|
||||
|
||||
onTabChange(status: DeletionRequestStatus): void {
|
||||
this.activeTab = status
|
||||
this.filterByStatus()
|
||||
}
|
||||
|
||||
viewDetails(request: DeletionRequest): void {
|
||||
const modalRef = this.modalService.open(DeletionRequestDetailComponent, {
|
||||
size: 'xl',
|
||||
backdrop: 'static',
|
||||
})
|
||||
modalRef.componentInstance.deletionRequest = request
|
||||
modalRef.result.then(
|
||||
(result) => {
|
||||
if (result === 'approved' || result === 'rejected') {
|
||||
this.loadDeletionRequests()
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// Modal dismissed
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
getStatusBadgeClass(status: DeletionRequestStatus): string {
|
||||
switch (status) {
|
||||
case DeletionRequestStatus.Pending:
|
||||
return 'bg-warning text-dark'
|
||||
case DeletionRequestStatus.Approved:
|
||||
return 'bg-success'
|
||||
case DeletionRequestStatus.Rejected:
|
||||
return 'bg-danger'
|
||||
case DeletionRequestStatus.Completed:
|
||||
return 'bg-info'
|
||||
case DeletionRequestStatus.Cancelled:
|
||||
return 'bg-secondary'
|
||||
default:
|
||||
return 'bg-secondary'
|
||||
}
|
||||
}
|
||||
|
||||
getPendingCount(): number {
|
||||
return this.deletionRequests.filter(
|
||||
(req) => req.status === DeletionRequestStatus.Pending
|
||||
).length
|
||||
}
|
||||
|
||||
getStatusCount(status: DeletionRequestStatus): number {
|
||||
return this.deletionRequests.filter((req) => req.status === status).length
|
||||
}
|
||||
}
|
||||
50
src-ui/src/app/data/deletion-request.ts
Normal file
50
src-ui/src/app/data/deletion-request.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { ObjectWithId } from './object-with-id'
|
||||
|
||||
export interface DeletionRequestDocument {
|
||||
id: number
|
||||
title: string
|
||||
created: string
|
||||
correspondent?: string
|
||||
document_type?: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
export interface DeletionRequestImpactSummary {
|
||||
document_count: number
|
||||
documents: DeletionRequestDocument[]
|
||||
affected_tags: string[]
|
||||
affected_correspondents: string[]
|
||||
affected_types: string[]
|
||||
date_range?: {
|
||||
earliest: string
|
||||
latest: string
|
||||
}
|
||||
}
|
||||
|
||||
export enum DeletionRequestStatus {
|
||||
Pending = 'pending',
|
||||
Approved = 'approved',
|
||||
Rejected = 'rejected',
|
||||
Cancelled = 'cancelled',
|
||||
Completed = 'completed',
|
||||
}
|
||||
|
||||
export interface DeletionRequest extends ObjectWithId {
|
||||
created_at: string
|
||||
updated_at: string
|
||||
requested_by_ai: boolean
|
||||
ai_reason: string
|
||||
user: number
|
||||
user_username: string
|
||||
status: DeletionRequestStatus
|
||||
documents: number[]
|
||||
documents_detail: DeletionRequestDocument[]
|
||||
document_count: number
|
||||
impact_summary: DeletionRequestImpactSummary
|
||||
reviewed_at?: string
|
||||
reviewed_by?: number
|
||||
reviewed_by_username?: string
|
||||
review_comment?: string
|
||||
completed_at?: string
|
||||
completion_details?: any
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { TestBed } from '@angular/core/testing'
|
||||
import {
|
||||
HttpClientTestingModule,
|
||||
HttpTestingController,
|
||||
} from '@angular/common/http/testing'
|
||||
import { DeletionRequestService } from './deletion-request.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
|
||||
describe('DeletionRequestService', () => {
|
||||
let service: DeletionRequestService
|
||||
let httpMock: HttpTestingController
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [DeletionRequestService],
|
||||
})
|
||||
service = TestBed.inject(DeletionRequestService)
|
||||
httpMock = TestBed.inject(HttpTestingController)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify()
|
||||
})
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should get pending count', () => {
|
||||
const mockResponse = { count: 5 }
|
||||
|
||||
service.getPendingCount().subscribe((response) => {
|
||||
expect(response.count).toBe(5)
|
||||
})
|
||||
|
||||
const req = httpMock.expectOne(
|
||||
`${environment.apiBaseUrl}deletion_requests/pending_count/`
|
||||
)
|
||||
expect(req.request.method).toBe('GET')
|
||||
req.flush(mockResponse)
|
||||
})
|
||||
|
||||
it('should approve a deletion request', () => {
|
||||
const mockRequest = {
|
||||
id: 1,
|
||||
status: 'approved',
|
||||
}
|
||||
|
||||
service.approve(1, 'Approved').subscribe((response) => {
|
||||
expect(response.status).toBe('approved')
|
||||
})
|
||||
|
||||
const req = httpMock.expectOne(
|
||||
`${environment.apiBaseUrl}deletion_requests/1/approve/`
|
||||
)
|
||||
expect(req.request.method).toBe('POST')
|
||||
expect(req.request.body).toEqual({ review_comment: 'Approved' })
|
||||
req.flush(mockRequest)
|
||||
})
|
||||
|
||||
it('should reject a deletion request', () => {
|
||||
const mockRequest = {
|
||||
id: 1,
|
||||
status: 'rejected',
|
||||
}
|
||||
|
||||
service.reject(1, 'Rejected').subscribe((response) => {
|
||||
expect(response.status).toBe('rejected')
|
||||
})
|
||||
|
||||
const req = httpMock.expectOne(
|
||||
`${environment.apiBaseUrl}deletion_requests/1/reject/`
|
||||
)
|
||||
expect(req.request.method).toBe('POST')
|
||||
expect(req.request.body).toEqual({ review_comment: 'Rejected' })
|
||||
req.flush(mockRequest)
|
||||
})
|
||||
})
|
||||
62
src-ui/src/app/services/rest/deletion-request.service.ts
Normal file
62
src-ui/src/app/services/rest/deletion-request.service.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { HttpClient } from '@angular/common/http'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { Observable } from 'rxjs'
|
||||
import { tap } from 'rxjs/operators'
|
||||
import { DeletionRequest } from 'src/app/data/deletion-request'
|
||||
import { AbstractPaperlessService } from './abstract-paperless-service'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class DeletionRequestService extends AbstractPaperlessService<DeletionRequest> {
|
||||
constructor() {
|
||||
super()
|
||||
this.resourceName = 'deletion_requests'
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a deletion request
|
||||
* @param id The ID of the deletion request
|
||||
* @param reviewComment Optional comment for the approval
|
||||
* @returns Observable of the updated deletion request
|
||||
*/
|
||||
approve(id: number, reviewComment?: string): Observable<DeletionRequest> {
|
||||
this._loading = true
|
||||
const body = reviewComment ? { review_comment: reviewComment } : {}
|
||||
return this.http
|
||||
.post<DeletionRequest>(this.getResourceUrl(id, 'approve'), body)
|
||||
.pipe(
|
||||
tap(() => {
|
||||
this._loading = false
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a deletion request
|
||||
* @param id The ID of the deletion request
|
||||
* @param reviewComment Optional comment for the rejection
|
||||
* @returns Observable of the updated deletion request
|
||||
*/
|
||||
reject(id: number, reviewComment?: string): Observable<DeletionRequest> {
|
||||
this._loading = true
|
||||
const body = reviewComment ? { review_comment: reviewComment } : {}
|
||||
return this.http
|
||||
.post<DeletionRequest>(this.getResourceUrl(id, 'reject'), body)
|
||||
.pipe(
|
||||
tap(() => {
|
||||
this._loading = false
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the count of pending deletion requests
|
||||
* @returns Observable with the count
|
||||
*/
|
||||
getPendingCount(): Observable<{ count: number }> {
|
||||
return this.http.get<{ count: number }>(
|
||||
this.getResourceUrl(null, 'pending_count')
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue