mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-12-10 00:35:30 +01:00
Merge pull request #53 from dawnsystem/copilot/add-deletion-requests-dashboard
Add UI for deletion requests management
This commit is contained in:
commit
77f1593d0a
17 changed files with 22875 additions and 0 deletions
|
|
@ -1,4 +1,5 @@
|
|||
# 📝 Bitácora Maestra del Proyecto: IntelliDocs-ngx
|
||||
*Última actualización: 2025-11-15 15:31:00 UTC*
|
||||
*Última actualización: 2025-11-14 16:05:48 UTC*
|
||||
*Última actualización: 2025-11-13 05:43:00 UTC*
|
||||
*Última actualización: 2025-11-12 13:30:00 UTC*
|
||||
|
|
@ -19,6 +20,7 @@ Estado actual: **A la espera de nuevas directivas del Director.**
|
|||
### ✅ Historial de Implementaciones Completadas
|
||||
*(En orden cronológico inverso. Cada entrada es un hito de negocio finalizado)*
|
||||
|
||||
* **[2025-11-15] - `TSK-DELETION-UI-001` - UI para Gestión de Deletion Requests:** Implementación completa del dashboard para gestionar deletion requests iniciados por IA. Backend: DeletionRequestSerializer y DeletionRequestActionSerializer (serializers.py), DeletionRequestViewSet con acciones approve/reject/pending_count (views.py), ruta /api/deletion_requests/ (urls.py). Frontend Angular: deletion-request.ts (modelo de datos TypeScript), deletion-request.service.ts (servicio REST con CRUD completo), DeletionRequestsComponent (componente principal con filtrado por pestañas: pending/approved/rejected/completed, badge de notificación, tabla con paginación), DeletionRequestDetailComponent (modal con información completa, análisis de impacto visual, lista de documentos afectados, botones approve/reject), ruta /deletion-requests con guard de permisos. Diseño consistente con resto de app (ng-bootstrap, badges de colores, layout responsive). Validaciones: lint ✓, build ✓, tests spec creados. Cumple 100% criterios de aceptación del issue #17.
|
||||
* **[2025-11-14] - `TSK-ML-CACHE-001` - Sistema de Caché de Modelos ML con Optimización de Rendimiento:** Implementación completa de sistema de caché eficiente para modelos ML. 7 archivos modificados/creados: model_cache.py (381 líneas - ModelCacheManager singleton, LRUCache, CacheMetrics, disk cache para embeddings), classifier.py (integración cache), ner.py (integración cache), semantic_search.py (integración cache + disk embeddings), ai_scanner.py (métodos warm_up_models, get_cache_metrics, clear_cache), apps.py (_initialize_ml_cache con warm-up opcional), settings.py (PAPERLESS_ML_CACHE_MAX_MODELS=3, PAPERLESS_ML_CACHE_WARMUP=False), test_ml_cache.py (298 líneas - tests comprehensivos). Características: singleton pattern para instancia única por tipo modelo, LRU eviction con max_size configurable (default 3 modelos), cache en disco persistente para embeddings, métricas de performance (hits/misses/evictions/hit_rate), warm-up opcional en startup, thread-safe operations. Criterios aceptación cumplidos 100%: primera carga lenta (descarga modelo) + subsecuentes rápidas (10-100x más rápido desde cache), memoria controlada <2GB con LRU eviction, cache hits >90% después warm-up. Sistema optimiza significativamente rendimiento del AI Scanner eliminando recargas innecesarias de modelos pesados.
|
||||
* **[2025-11-13] - `TSK-API-DELETION-REQUESTS` - API Endpoints para Gestión de Deletion Requests:** Implementación completa de endpoints REST API para workflow de aprobación de deletion requests. 5 archivos creados/modificados: views/deletion_request.py (263 líneas - DeletionRequestViewSet con CRUD + acciones approve/reject/cancel), serialisers.py (DeletionRequestSerializer con document_details), urls.py (registro de ruta /api/deletion-requests/), views/__init__.py, test_api_deletion_requests.py (440 líneas - 20+ tests). Endpoints: GET/POST/PATCH/DELETE /api/deletion-requests/, POST /api/deletion-requests/{id}/approve/, POST /api/deletion-requests/{id}/reject/, POST /api/deletion-requests/{id}/cancel/. Validaciones: permisos (owner o admin), estado (solo pending puede aprobarse/rechazarse/cancelarse). Approve ejecuta eliminación de documentos en transacción atómica y retorna execution_result con deleted_count y failed_deletions. Queryset filtrado por usuario (admins ven todos, users ven solo los suyos). Tests cubren: permisos, validaciones de estado, ejecución correcta, manejo de errores, múltiples documentos. 100% funcional vía API.
|
||||
* **[2025-11-12] - `TSK-AI-SCANNER-LINTING` - Pre-commit Hooks y Linting del AI Scanner:** Corrección completa de todos los warnings de linting en los 3 archivos del AI Scanner. Archivos actualizados: ai_scanner.py (38 cambios), ai_deletion_manager.py (4 cambios), consumer.py (22 cambios). Correcciones aplicadas: (1) Import ordering (TC002) - movido User a bloque TYPE_CHECKING en ai_deletion_manager.py, (2) Type hints implícitos (RUF013) - actualizados 3 parámetros bool=None a bool|None=None en ai_scanner.py, (3) Boolean traps (FBT001/FBT002) - convertidos 4 parámetros boolean a keyword-only usando * en __init__() y apply_scan_results(), (4) Logging warnings (G201) - reemplazadas 10 instancias de logger.error(..., exc_info=True) por logger.exception(), (5) Espacios en blanco (W293) - eliminados en ~100+ líneas, (6) Trailing commas (COM812) - corregidas automáticamente. Herramientas ejecutadas: ruff check (0 warnings), ruff format (código formateado), black (formateo consistente). Estado final: ✅ CERO warnings de linters, ✅ código pasa todas las verificaciones de ruff, ✅ formateo consistente aplicado. El código está ahora listo para pre-commit hooks y cumple con todos los estándares de calidad del proyecto.
|
||||
|
|
@ -47,6 +49,50 @@ Estado actual: **A la espera de nuevas directivas del Director.**
|
|||
|
||||
## 🔬 Registro Forense de Sesiones (Log Detallado)
|
||||
|
||||
### Sesión Iniciada: 2025-11-15 15:19:00 UTC
|
||||
|
||||
* **Directiva del Director:** "hubo un problema, revisa lo que este hecho y repara, implemeta y haz lo que falte, si se trata de UI que cuadre con el resto de la app"
|
||||
* **Plan de Acción Propuesto:**
|
||||
1. Revisar commits anteriores y estado del código
|
||||
2. Completar implementación del backend API (serializers, ViewSet, routes)
|
||||
3. Crear servicio frontend para consumir API
|
||||
4. Implementar componente de listado con filtros por estado
|
||||
5. Implementar componente de detalle con análisis de impacto
|
||||
6. Añadir routing y validar build
|
||||
* **Log de Acciones (con timestamp):**
|
||||
* `15:19:00` - **ACCIÓN:** Análisis de código. **DETALLE:** Revisión de commits previos (780decf, 1b4bc75), verificación de cambios en serialisers.py y views.py. **RESULTADO:** Serializers creados pero ViewSet incompleto, frontend sin implementar.
|
||||
* `15:20:00` - **ACCIÓN:** Modificación de fichero. **DETALLE:** `src/documents/views.py`. **CAMBIOS:** Añadido DeletionRequestViewSet completo (120 líneas) con métodos list, retrieve, approve, reject, pending_count. Extends ModelViewSet, usa IsAuthenticated permissions, DjangoFilterBackend para filtrado.
|
||||
* `15:21:00` - **ACCIÓN:** Modificación de fichero. **DETALLE:** `src/paperless/urls.py`. **CAMBIOS:** Importado DeletionRequestViewSet, registrado en api_router: `api_router.register(r"deletion_requests", DeletionRequestViewSet)`.
|
||||
* `15:22:00` - **ACCIÓN:** Validación de sintaxis. **COMANDO:** `python3 -m py_compile` en views.py y urls.py. **RESULTADO:** OK (✓).
|
||||
* `15:23:00` - **ACCIÓN:** Creación de fichero. **DETALLE:** `src-ui/src/app/data/deletion-request.ts` (1153 bytes). **MOTIVO:** Interfaces TypeScript para DeletionRequest, DeletionRequestDocument, DeletionRequestImpactSummary, enum DeletionRequestStatus.
|
||||
* `15:24:00` - **ACCIÓN:** Creación de fichero. **DETALLE:** `src-ui/src/app/services/rest/deletion-request.service.ts` (1925 bytes). **MOTIVO:** Servicio Angular extendiendo AbstractPaperlessService con métodos approve(), reject(), getPendingCount().
|
||||
* `15:24:30` - **ACCIÓN:** Creación de fichero. **DETALLE:** `src-ui/src/app/services/rest/deletion-request.service.spec.ts` (2119 bytes). **MOTIVO:** Tests unitarios para servicio.
|
||||
* `15:25:00` - **ACCIÓN:** Creación de componente. **DETALLE:** `src-ui/src/app/components/deletion-requests/deletion-requests.component.*` (4 archivos). **MOTIVO:** Componente principal con filtrado por tabs (pending/approved/rejected/completed), tabla con paginación, badges de estado, contador de pendientes.
|
||||
* `15:26:00` - **ACCIÓN:** Creación de componente. **DETALLE:** `src-ui/src/app/components/deletion-requests/deletion-request-detail/*` (4 archivos). **MOTIVO:** Modal de detalle con información completa, análisis de impacto visual (cards con métricas), lista de documentos, botones approve/reject.
|
||||
* `15:27:00` - **ACCIÓN:** Modificación de fichero. **DETALLE:** `src-ui/src/app/app-routing.module.ts`. **CAMBIOS:** Importado DeletionRequestsComponent, añadida ruta `/deletion-requests` con PermissionsGuard.
|
||||
* `15:28:00` - **ACCIÓN:** Instalación de dependencias. **COMANDO:** `pnpm install`. **RESULTADO:** Dependencias instaladas correctamente.
|
||||
* `15:28:30` - **ACCIÓN:** Lint frontend. **COMANDO:** `pnpm run lint`. **RESULTADO:** 2 errores iniciales (constructor injection, template filter). Corregidos: eliminado parámetro http del constructor, añadido método getStatusCount().
|
||||
* `15:29:00` - **ACCIÓN:** Lint frontend (2da vez). **COMANDO:** `pnpm run lint`. **RESULTADO:** ✓ All files pass linting.
|
||||
* `15:29:30` - **ACCIÓN:** Build frontend. **COMANDO:** `pnpm run build`. **RESULTADO:** 2 errores de compilación (private property, incorrect extends). Corregidos: deletionRequestService cambiado a public, unsubscribeNotifier cambiado a protected.
|
||||
* `15:30:00` - **ACCIÓN:** Build frontend (2da vez). **COMANDO:** `pnpm run build`. **RESULTADO:** ✓ Build successful (dist/paperless-ui generado).
|
||||
* `15:31:00` - **ACCIÓN:** Commit. **HASH:** `5edfbfc`. **MENSAJE:** `feat: Complete deletion requests management UI implementation`.
|
||||
* **Resultado de la Sesión:** Hito TSK-DELETION-UI-001 completado. UI funcional y consistente con diseño de la app.
|
||||
* **Commit Asociado:** `5edfbfc`
|
||||
* **Observaciones/Decisiones de Diseño:**
|
||||
- Backend: DeletionRequestViewSet filtra por usuario (get_queryset), solo superuser ve todos los requests
|
||||
- Backend: Acciones approve/reject validan status=pending antes de proceder
|
||||
- Backend: pending_count endpoint retorna {count: N} para badge de notificación
|
||||
- Frontend: Servicio usa inject() en lugar de constructor injection (preferencia Angular)
|
||||
- Frontend: DeletionRequestsComponent extiende LoadingComponentWithPermissions (patrón estándar app)
|
||||
- Frontend: Tabs con NgbNav para filtrado por estado, badge warning en tab Pending
|
||||
- Frontend: DeletionRequestDetailComponent usa modal XL responsive
|
||||
- Frontend: Análisis de impacto mostrado con cards visuales (document_count, tags, correspondents)
|
||||
- Frontend: Tabla de documentos afectados muestra: id, title, correspondent, type, tags
|
||||
- Frontend: Solo requests pending permiten approve/reject (canModify() guard)
|
||||
- Frontend: Botones con spinner durante procesamiento (isProcessing flag)
|
||||
- Frontend: Toast notifications para feedback de acciones
|
||||
- Frontend: Diseño consistente: ng-bootstrap components, badges con colores semánticos (warning/success/danger/info), CustomDatePipe para fechas
|
||||
- Frontend: Routing con PermissionsGuard (action: View, type: Document)
|
||||
### Sesión Iniciada: 2025-11-14 16:05:48 UTC
|
||||
|
||||
* **Directiva del Director:** "hubo un error, revisa todo e implementa lo que falte @copilot"
|
||||
|
|
|
|||
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')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -49,6 +49,7 @@ from documents.filters import CustomFieldQueryParser
|
|||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
from documents.models import CustomFieldInstance
|
||||
from documents.models import DeletionRequest
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import MatchingModel
|
||||
|
|
@ -2699,6 +2700,84 @@ class StoragePathTestSerializer(SerializerWithPerms):
|
|||
|
||||
|
||||
class DeletionRequestSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serializer for DeletionRequest model.
|
||||
Provides full CRUD operations for AI-initiated deletion requests.
|
||||
"""
|
||||
|
||||
user_username = serializers.CharField(
|
||||
source="user.username",
|
||||
read_only=True,
|
||||
label="User",
|
||||
)
|
||||
|
||||
reviewed_by_username = serializers.CharField(
|
||||
source="reviewed_by.username",
|
||||
read_only=True,
|
||||
label="Reviewed By",
|
||||
allow_null=True,
|
||||
)
|
||||
|
||||
document_count = serializers.SerializerMethodField(
|
||||
label="Document Count",
|
||||
read_only=True,
|
||||
)
|
||||
|
||||
documents_detail = serializers.SerializerMethodField(
|
||||
label="Documents",
|
||||
read_only=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = DeletionRequest
|
||||
fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"requested_by_ai",
|
||||
"ai_reason",
|
||||
"user",
|
||||
"user_username",
|
||||
"status",
|
||||
"documents",
|
||||
"documents_detail",
|
||||
"document_count",
|
||||
"impact_summary",
|
||||
"reviewed_at",
|
||||
"reviewed_by",
|
||||
"reviewed_by_username",
|
||||
"review_comment",
|
||||
"completed_at",
|
||||
"completion_details",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"reviewed_at",
|
||||
"reviewed_by",
|
||||
"completed_at",
|
||||
]
|
||||
|
||||
def get_document_count(self, obj):
|
||||
"""Get the count of documents in this deletion request."""
|
||||
return obj.documents.count()
|
||||
|
||||
def get_documents_detail(self, obj):
|
||||
"""Get detailed information about documents in this deletion request."""
|
||||
documents = obj.documents.all()[:100] # Limit to prevent large responses
|
||||
return [
|
||||
{
|
||||
"id": doc.id,
|
||||
"title": doc.title,
|
||||
"created": doc.created,
|
||||
"correspondent": (
|
||||
doc.correspondent.name if doc.correspondent else None
|
||||
),
|
||||
"document_type": (
|
||||
doc.document_type.name if doc.document_type else None
|
||||
),
|
||||
"tags": [tag.name for tag in doc.tags.all()],
|
||||
"""Serializer for DeletionRequest model with document details."""
|
||||
|
||||
document_details = serializers.SerializerMethodField()
|
||||
|
|
@ -2756,6 +2835,16 @@ class DeletionRequestSerializer(serializers.ModelSerializer):
|
|||
]
|
||||
|
||||
|
||||
class DeletionRequestActionSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for approve/reject actions on DeletionRequest.
|
||||
"""
|
||||
|
||||
review_comment = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
label="Review Comment",
|
||||
help_text="Optional comment when reviewing the deletion request",
|
||||
class AISuggestionsRequestSerializer(serializers.Serializer):
|
||||
"""Serializer for requesting AI suggestions for a document."""
|
||||
|
||||
|
|
|
|||
|
|
@ -169,6 +169,8 @@ from documents.serialisers import BulkEditObjectsSerializer
|
|||
from documents.serialisers import BulkEditSerializer
|
||||
from documents.serialisers import CorrespondentSerializer
|
||||
from documents.serialisers import CustomFieldSerializer
|
||||
from documents.serialisers import DeletionRequestActionSerializer
|
||||
from documents.serialisers import DeletionRequestSerializer
|
||||
from documents.serialisers import DocumentListSerializer
|
||||
from documents.serialisers import DocumentSerializer
|
||||
from documents.serialisers import DocumentTypeSerializer
|
||||
|
|
@ -3377,6 +3379,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
|
||||
|
|
|
|||
|
|
@ -23,6 +23,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
|
||||
|
|
@ -83,6 +84,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)
|
||||
api_router.register(r"deletion-requests", DeletionRequestViewSet, basename="deletion-requests")
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue