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:
copilot-swe-agent[bot] 2025-11-15 15:33:45 +00:00
parent 1b4bc75a80
commit 5edfbfc028
15 changed files with 22738 additions and 0 deletions

21751
src-ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -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',

View file

@ -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>

View file

@ -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()
})
})

View file

@ -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
}
}

View file

@ -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>
}
}

View file

@ -0,0 +1,6 @@
// Component-specific styles for deletion requests
.text-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View file

@ -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()
})
})

View file

@ -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
}
}

View 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
}

View file

@ -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)
})
})

View 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')
)
}
}

View file

@ -3095,6 +3095,122 @@ class SystemStatusView(PassUserMixin):
)
class DeletionRequestViewSet(ModelViewSet):
"""
ViewSet for managing DeletionRequest objects.
Provides list, retrieve, approve, and reject operations.
"""
permission_classes = (IsAuthenticated,)
serializer_class = DeletionRequestSerializer
pagination_class = StandardPagination
filter_backends = (DjangoFilterBackend, OrderingFilter)
ordering_fields = ("created_at", "updated_at", "status")
ordering = ("-created_at",)
def get_queryset(self):
"""
Filter deletion requests by user.
Only show requests for the current user or requests they can manage.
"""
user = self.request.user
if user.is_superuser:
return DeletionRequest.objects.all()
return DeletionRequest.objects.filter(user=user)
def get_serializer_class(self):
"""Use different serializers for different actions."""
if self.action in ["approve", "reject"]:
return DeletionRequestActionSerializer
return DeletionRequestSerializer
@extend_schema(
request=DeletionRequestActionSerializer,
responses={200: DeletionRequestSerializer},
)
@action(detail=True, methods=["post"])
def approve(self, request, pk=None):
"""
Approve a deletion request.
"""
deletion_request = self.get_object()
if deletion_request.status != DeletionRequest.STATUS_PENDING:
return Response(
{"error": "Only pending requests can be approved"},
status=400,
)
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
comment = serializer.validated_data.get("review_comment", "")
success = deletion_request.approve(request.user, comment)
if success:
return Response(
DeletionRequestSerializer(deletion_request).data,
status=200,
)
else:
return Response(
{"error": "Failed to approve deletion request"},
status=400,
)
@extend_schema(
request=DeletionRequestActionSerializer,
responses={200: DeletionRequestSerializer},
)
@action(detail=True, methods=["post"])
def reject(self, request, pk=None):
"""
Reject a deletion request.
"""
deletion_request = self.get_object()
if deletion_request.status != DeletionRequest.STATUS_PENDING:
return Response(
{"error": "Only pending requests can be rejected"},
status=400,
)
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
comment = serializer.validated_data.get("review_comment", "")
success = deletion_request.reject(request.user, comment)
if success:
return Response(
DeletionRequestSerializer(deletion_request).data,
status=200,
)
else:
return Response(
{"error": "Failed to reject deletion request"},
status=400,
)
@extend_schema(
responses={200: inline_serializer(
name="PendingCountResponse",
fields={"count": serializers.IntegerField()},
)},
)
@action(detail=False, methods=["get"])
def pending_count(self, request):
"""
Get the count of pending deletion requests for the current user.
"""
user = request.user
count = DeletionRequest.objects.filter(
user=user,
status=DeletionRequest.STATUS_PENDING,
).count()
return Response({"count": count})
class TrashView(ListModelMixin, PassUserMixin):
permission_classes = (IsAuthenticated,)
serializer_class = TrashSerializer

View file

@ -20,6 +20,7 @@ from documents.views import BulkEditObjectsView
from documents.views import BulkEditView
from documents.views import CorrespondentViewSet
from documents.views import CustomFieldViewSet
from documents.views import DeletionRequestViewSet
from documents.views import DocumentTypeViewSet
from documents.views import GlobalSearchView
from documents.views import IndexView
@ -79,6 +80,7 @@ api_router.register(r"workflows", WorkflowViewSet)
api_router.register(r"custom_fields", CustomFieldViewSet)
api_router.register(r"config", ApplicationConfigurationViewSet)
api_router.register(r"processed_mail", ProcessedMailViewSet)
api_router.register(r"deletion_requests", DeletionRequestViewSet)
urlpatterns = [