mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-09-16 21:55:37 -05:00
Enhancement: add processed mails management UI and API
This commit is contained in:
@@ -168,6 +168,13 @@
|
|||||||
<i-bs width="1em" height="1em" name="files"></i-bs> <ng-container i18n>Copy</ng-container>
|
<i-bs width="1em" height="1em" name="files"></i-bs> <ng-container i18n>Copy</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="btn-group">
|
||||||
|
<div *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ProcessedMail }">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="viewProcessedMails(rule)">
|
||||||
|
<i-bs width="1em" height="1em" name="clock-history"></i-bs> <ng-container i18n>Processed Mails</ng-container>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -27,6 +27,7 @@ import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-
|
|||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
|
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
|
||||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||||
|
import { ProcessedMailsDialogComponent } from './processed-mails-dialog/processed-mails-dialog.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-mail',
|
selector: 'pngx-mail',
|
||||||
@@ -347,6 +348,14 @@ export class MailComponent
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
viewProcessedMails(rule: MailRule) {
|
||||||
|
const modal = this.modalService.open(ProcessedMailsDialogComponent, {
|
||||||
|
backdrop: 'static',
|
||||||
|
size: 'xl',
|
||||||
|
})
|
||||||
|
modal.componentInstance.rule = rule
|
||||||
|
}
|
||||||
|
|
||||||
userCanEdit(obj: ObjectWithPermissions): boolean {
|
userCanEdit(obj: ObjectWithPermissions): boolean {
|
||||||
return this.permissionsService.currentUserHasObjectPermissions(
|
return this.permissionsService.currentUserHasObjectPermissions(
|
||||||
PermissionAction.Change,
|
PermissionAction.Change,
|
||||||
|
@@ -0,0 +1,92 @@
|
|||||||
|
<div class="modal-header">
|
||||||
|
<h6 class="modal-title" id="modal-basic-title" i18n>Processed Mails for <em>{{ rule.name }}</em></h6>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
@if (loading) {
|
||||||
|
<div class="text-center my-5">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden" i18n>Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else if (processedMails.length === 0) {
|
||||||
|
<span i18n>No processed mails found.</span>
|
||||||
|
} @else {
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover table-sm align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" style="width: 40px;">
|
||||||
|
<div class="form-check m-0 ms-2 me-n2">
|
||||||
|
<input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="toggleAllEnabled" [disabled]="processedMails.length === 0" (click)="toggleAll($event); $event.stopPropagation();">
|
||||||
|
<label class="form-check-label" for="all-objects"></label>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th scope="col" i18n>Subject</th>
|
||||||
|
<th scope="col" i18n>Received</th>
|
||||||
|
<th scope="col" i18n>Processed</th>
|
||||||
|
<th scope="col" i18n>Status</th>
|
||||||
|
<th scope="col" i18n>Error</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (mail of processedMails; track mail.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="form-check m-0 ms-2 me-n2">
|
||||||
|
<input type="checkbox" class="form-check-input" [id]="mail.id" [checked]="selectedMailIds.has(mail.id)" (click)="toggleSelected(mail); $event.stopPropagation();">
|
||||||
|
<label class="form-check-label" [for]="mail.id"></label>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ mail.subject }}</td>
|
||||||
|
<td>{{ mail.received | customDate:'longDate' }}</td>
|
||||||
|
<td>{{ mail.processed | customDate:'longDate' }}</td>
|
||||||
|
<td>
|
||||||
|
@switch (mail.status) {
|
||||||
|
@case ('SUCCESS') {
|
||||||
|
<i-bs name="check-circle" title="SUCCESS" class="text-success" [ngbTooltip]="mail.status"></i-bs>
|
||||||
|
}
|
||||||
|
@case ('FAILED') {
|
||||||
|
<i-bs name="exclamation-triangle" title="FAILED" class="text-danger" [ngbTooltip]="mail.status"></i-bs>
|
||||||
|
}
|
||||||
|
@default {
|
||||||
|
<i-bs name="question-circle" title="{{ mail.status }}" class="text-muted" [ngbTooltip]="mail.status"></i-bs>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (mail.error) {
|
||||||
|
<span class="text-danger">{{ mail.error | slice:0:20 }}</span>
|
||||||
|
} @else {
|
||||||
|
<span class="text-muted" i18n>None</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="btn-toolbar">
|
||||||
|
<button type="button" class="btn btn-outline-secondary me-2" (click)="clearSelection()" [disabled]="selectedMailIds.size === 0" i18n>Clear</button>
|
||||||
|
<pngx-confirm-button
|
||||||
|
label="Delete selected"
|
||||||
|
i18n-label
|
||||||
|
title="Delete selected"
|
||||||
|
i18n-title
|
||||||
|
buttonClasses="btn-outline-danger"
|
||||||
|
iconName="trash"
|
||||||
|
[disabled]="selectedMailIds.size === 0"
|
||||||
|
(confirm)="deleteSelected()">
|
||||||
|
</pngx-confirm-button>
|
||||||
|
<div class="ms-auto">
|
||||||
|
<ngb-pagination
|
||||||
|
[collectionSize]="processedMails.length"
|
||||||
|
[(page)]="page"
|
||||||
|
[pageSize]="50"
|
||||||
|
[maxSize]="5"
|
||||||
|
(pageChange)="loadProcessedMails()">
|
||||||
|
</ngb-pagination>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
@@ -0,0 +1,91 @@
|
|||||||
|
import { SlicePipe } from '@angular/common'
|
||||||
|
import { Component, inject, Input } from '@angular/core'
|
||||||
|
import {
|
||||||
|
NgbActiveModal,
|
||||||
|
NgbPagination,
|
||||||
|
NgbTooltipModule,
|
||||||
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { ConfirmButtonComponent } from 'src/app/components/common/confirm-button/confirm-button.component'
|
||||||
|
import { MailRule } from 'src/app/data/mail-rule'
|
||||||
|
import { ProcessedMail } from 'src/app/data/processed-mail'
|
||||||
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
|
import { ProcessedMailService } from 'src/app/services/rest/processed-mail.service'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-processed-mails-dialog',
|
||||||
|
imports: [
|
||||||
|
ConfirmButtonComponent,
|
||||||
|
CustomDatePipe,
|
||||||
|
NgbPagination,
|
||||||
|
NgbTooltipModule,
|
||||||
|
NgxBootstrapIconsModule,
|
||||||
|
SlicePipe,
|
||||||
|
],
|
||||||
|
templateUrl: './processed-mails-dialog.component.html',
|
||||||
|
styleUrl: './processed-mails-dialog.component.scss',
|
||||||
|
})
|
||||||
|
export class ProcessedMailsDialogComponent {
|
||||||
|
private activeModal = inject(NgbActiveModal)
|
||||||
|
private processedMailService = inject(ProcessedMailService)
|
||||||
|
private toastService = inject(ToastService)
|
||||||
|
|
||||||
|
public processedMails: ProcessedMail[] = []
|
||||||
|
|
||||||
|
public loading: boolean = true
|
||||||
|
public toggleAllEnabled: boolean = false
|
||||||
|
public readonly selectedMailIds: Set<number> = new Set<number>()
|
||||||
|
|
||||||
|
public page: number = 1
|
||||||
|
|
||||||
|
@Input() rule: MailRule
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadProcessedMails()
|
||||||
|
}
|
||||||
|
|
||||||
|
public close() {
|
||||||
|
this.activeModal.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadProcessedMails(): void {
|
||||||
|
this.loading = true
|
||||||
|
this.clearSelection()
|
||||||
|
this.processedMailService
|
||||||
|
.list(this.page, 50, 'processed_at', true, { rule: this.rule.id })
|
||||||
|
.subscribe((result) => {
|
||||||
|
this.processedMails = result.results
|
||||||
|
this.loading = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public deleteSelected(): void {
|
||||||
|
this.processedMailService
|
||||||
|
.bulk_delete(Array.from(this.selectedMailIds))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.toastService.showInfo($localize`Processed mail(s) deleted`)
|
||||||
|
this.loadProcessedMails()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public toggleAll(event: PointerEvent) {
|
||||||
|
if ((event.target as HTMLInputElement).checked) {
|
||||||
|
this.selectedMailIds.clear()
|
||||||
|
this.processedMails.forEach((mail) => this.selectedMailIds.add(mail.id))
|
||||||
|
} else {
|
||||||
|
this.clearSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearSelection() {
|
||||||
|
this.toggleAllEnabled = false
|
||||||
|
this.selectedMailIds.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
public toggleSelected(mail: ProcessedMail) {
|
||||||
|
this.selectedMailIds.has(mail.id)
|
||||||
|
? this.selectedMailIds.delete(mail.id)
|
||||||
|
: this.selectedMailIds.add(mail.id)
|
||||||
|
}
|
||||||
|
}
|
12
src-ui/src/app/data/processed-mail.ts
Normal file
12
src-ui/src/app/data/processed-mail.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { ObjectWithId } from './object-with-id'
|
||||||
|
|
||||||
|
export interface ProcessedMail extends ObjectWithId {
|
||||||
|
rule: number // MailRule.id
|
||||||
|
folder: string
|
||||||
|
uid: number
|
||||||
|
subject: string
|
||||||
|
received: Date
|
||||||
|
processed: Date
|
||||||
|
status: string
|
||||||
|
error: string
|
||||||
|
}
|
@@ -28,6 +28,7 @@ export enum PermissionType {
|
|||||||
ShareLink = '%s_sharelink',
|
ShareLink = '%s_sharelink',
|
||||||
CustomField = '%s_customfield',
|
CustomField = '%s_customfield',
|
||||||
Workflow = '%s_workflow',
|
Workflow = '%s_workflow',
|
||||||
|
ProcessedMail = '%s_processedmail',
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
|
19
src-ui/src/app/services/rest/processed-mail.service.ts
Normal file
19
src-ui/src/app/services/rest/processed-mail.service.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Injectable } from '@angular/core'
|
||||||
|
import { ProcessedMail } from 'src/app/data/processed-mail'
|
||||||
|
import { AbstractPaperlessService } from './abstract-paperless-service'
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class ProcessedMailService extends AbstractPaperlessService<ProcessedMail> {
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this.resourceName = 'processed_mail'
|
||||||
|
}
|
||||||
|
|
||||||
|
public bulk_delete(mailIds: number[]) {
|
||||||
|
return this.http.post(`${this.getResourceUrl()}bulk_delete/`, {
|
||||||
|
mail_ids: mailIds,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@@ -51,6 +51,7 @@ import {
|
|||||||
check,
|
check,
|
||||||
check2All,
|
check2All,
|
||||||
checkAll,
|
checkAll,
|
||||||
|
checkCircle,
|
||||||
checkCircleFill,
|
checkCircleFill,
|
||||||
checkLg,
|
checkLg,
|
||||||
chevronDoubleLeft,
|
chevronDoubleLeft,
|
||||||
@@ -59,6 +60,7 @@ import {
|
|||||||
clipboardCheck,
|
clipboardCheck,
|
||||||
clipboardCheckFill,
|
clipboardCheckFill,
|
||||||
clipboardFill,
|
clipboardFill,
|
||||||
|
clockHistory,
|
||||||
dash,
|
dash,
|
||||||
dashCircle,
|
dashCircle,
|
||||||
diagram3,
|
diagram3,
|
||||||
@@ -261,6 +263,7 @@ const icons = {
|
|||||||
check,
|
check,
|
||||||
check2All,
|
check2All,
|
||||||
checkAll,
|
checkAll,
|
||||||
|
checkCircle,
|
||||||
checkCircleFill,
|
checkCircleFill,
|
||||||
checkLg,
|
checkLg,
|
||||||
chevronDoubleLeft,
|
chevronDoubleLeft,
|
||||||
@@ -269,6 +272,7 @@ const icons = {
|
|||||||
clipboardCheck,
|
clipboardCheck,
|
||||||
clipboardCheckFill,
|
clipboardCheckFill,
|
||||||
clipboardFill,
|
clipboardFill,
|
||||||
|
clockHistory,
|
||||||
dash,
|
dash,
|
||||||
dashCircle,
|
dashCircle,
|
||||||
diagram3,
|
diagram3,
|
||||||
|
@@ -41,6 +41,7 @@ from documents.models import PaperlessTask
|
|||||||
from documents.models import ShareLink
|
from documents.models import ShareLink
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
|
from paperless_mail.models import ProcessedMail
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@@ -802,6 +803,15 @@ class PaperlessTaskFilterSet(FilterSet):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessedMailFilterSet(FilterSet):
|
||||||
|
class Meta:
|
||||||
|
model = ProcessedMail
|
||||||
|
fields = {
|
||||||
|
"rule": ["exact"],
|
||||||
|
"status": ["exact"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class ObjectOwnedOrGrantedPermissionsFilter(ObjectPermissionsFilter):
|
class ObjectOwnedOrGrantedPermissionsFilter(ObjectPermissionsFilter):
|
||||||
"""
|
"""
|
||||||
A filter backend that limits results to those where the requesting user
|
A filter backend that limits results to those where the requesting user
|
||||||
|
@@ -107,6 +107,7 @@ from documents.filters import DocumentTypeFilterSet
|
|||||||
from documents.filters import ObjectOwnedOrGrantedPermissionsFilter
|
from documents.filters import ObjectOwnedOrGrantedPermissionsFilter
|
||||||
from documents.filters import ObjectOwnedPermissionsFilter
|
from documents.filters import ObjectOwnedPermissionsFilter
|
||||||
from documents.filters import PaperlessTaskFilterSet
|
from documents.filters import PaperlessTaskFilterSet
|
||||||
|
from documents.filters import ProcessedMailFilterSet
|
||||||
from documents.filters import ShareLinkFilterSet
|
from documents.filters import ShareLinkFilterSet
|
||||||
from documents.filters import StoragePathFilterSet
|
from documents.filters import StoragePathFilterSet
|
||||||
from documents.filters import TagFilterSet
|
from documents.filters import TagFilterSet
|
||||||
@@ -181,9 +182,11 @@ from paperless.serialisers import UserSerializer
|
|||||||
from paperless.views import StandardPagination
|
from paperless.views import StandardPagination
|
||||||
from paperless_mail.models import MailAccount
|
from paperless_mail.models import MailAccount
|
||||||
from paperless_mail.models import MailRule
|
from paperless_mail.models import MailRule
|
||||||
|
from paperless_mail.models import ProcessedMail
|
||||||
from paperless_mail.oauth import PaperlessMailOAuth2Manager
|
from paperless_mail.oauth import PaperlessMailOAuth2Manager
|
||||||
from paperless_mail.serialisers import MailAccountSerializer
|
from paperless_mail.serialisers import MailAccountSerializer
|
||||||
from paperless_mail.serialisers import MailRuleSerializer
|
from paperless_mail.serialisers import MailRuleSerializer
|
||||||
|
from paperless_mail.serialisers import ProcessedMailSerializer
|
||||||
|
|
||||||
if settings.AUDIT_LOG_ENABLED:
|
if settings.AUDIT_LOG_ENABLED:
|
||||||
from auditlog.models import LogEntry
|
from auditlog.models import LogEntry
|
||||||
@@ -2981,3 +2984,31 @@ def serve_logo(request, filename=None):
|
|||||||
filename=app_logo.name,
|
filename=app_logo.name,
|
||||||
as_attachment=True,
|
as_attachment=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessedMailViewSet(ReadOnlyModelViewSet, DestroyModelMixin, PassUserMixin):
|
||||||
|
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
|
||||||
|
serializer_class = ProcessedMailSerializer
|
||||||
|
pagination_class = StandardPagination
|
||||||
|
filter_backends = (
|
||||||
|
DjangoFilterBackend,
|
||||||
|
OrderingFilter,
|
||||||
|
ObjectOwnedOrGrantedPermissionsFilter,
|
||||||
|
)
|
||||||
|
filterset_class = ProcessedMailFilterSet
|
||||||
|
|
||||||
|
queryset = ProcessedMail.objects.all().order_by("-processed")
|
||||||
|
|
||||||
|
@action(methods=["post"], detail=False)
|
||||||
|
def bulk_delete(self, request):
|
||||||
|
mail_ids = request.data.get("mail_ids", [])
|
||||||
|
if not isinstance(mail_ids, list) or not all(
|
||||||
|
isinstance(i, int) for i in mail_ids
|
||||||
|
):
|
||||||
|
return HttpResponseBadRequest("mail_ids must be a list of integers")
|
||||||
|
mails = ProcessedMail.objects.filter(id__in=mail_ids)
|
||||||
|
for mail in mails:
|
||||||
|
if not has_perms_owner_aware(request.user, "delete_processedmail", mail):
|
||||||
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
|
mail.delete()
|
||||||
|
return Response({"result": "OK", "deleted_mail_ids": mail_ids})
|
||||||
|
@@ -25,6 +25,7 @@ from documents.views import GlobalSearchView
|
|||||||
from documents.views import IndexView
|
from documents.views import IndexView
|
||||||
from documents.views import LogViewSet
|
from documents.views import LogViewSet
|
||||||
from documents.views import PostDocumentView
|
from documents.views import PostDocumentView
|
||||||
|
from documents.views import ProcessedMailViewSet
|
||||||
from documents.views import RemoteVersionView
|
from documents.views import RemoteVersionView
|
||||||
from documents.views import SavedViewViewSet
|
from documents.views import SavedViewViewSet
|
||||||
from documents.views import SearchAutoCompleteView
|
from documents.views import SearchAutoCompleteView
|
||||||
@@ -77,6 +78,7 @@ api_router.register(r"workflow_actions", WorkflowActionViewSet)
|
|||||||
api_router.register(r"workflows", WorkflowViewSet)
|
api_router.register(r"workflows", WorkflowViewSet)
|
||||||
api_router.register(r"custom_fields", CustomFieldViewSet)
|
api_router.register(r"custom_fields", CustomFieldViewSet)
|
||||||
api_router.register(r"config", ApplicationConfigurationViewSet)
|
api_router.register(r"config", ApplicationConfigurationViewSet)
|
||||||
|
api_router.register(r"processed_mail", ProcessedMailViewSet)
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
@@ -6,6 +6,7 @@ from documents.serialisers import OwnedObjectSerializer
|
|||||||
from documents.serialisers import TagsField
|
from documents.serialisers import TagsField
|
||||||
from paperless_mail.models import MailAccount
|
from paperless_mail.models import MailAccount
|
||||||
from paperless_mail.models import MailRule
|
from paperless_mail.models import MailRule
|
||||||
|
from paperless_mail.models import ProcessedMail
|
||||||
|
|
||||||
|
|
||||||
class ObfuscatedPasswordField(serializers.CharField):
|
class ObfuscatedPasswordField(serializers.CharField):
|
||||||
@@ -130,3 +131,20 @@ class MailRuleSerializer(OwnedObjectSerializer):
|
|||||||
if value > 36500: # ~100 years
|
if value > 36500: # ~100 years
|
||||||
raise serializers.ValidationError("Maximum mail age is unreasonably large.")
|
raise serializers.ValidationError("Maximum mail age is unreasonably large.")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessedMailSerializer(OwnedObjectSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = ProcessedMail
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"owner",
|
||||||
|
"rule",
|
||||||
|
"folder",
|
||||||
|
"uid",
|
||||||
|
"subject",
|
||||||
|
"received",
|
||||||
|
"processed",
|
||||||
|
"status",
|
||||||
|
"error",
|
||||||
|
]
|
||||||
|
Reference in New Issue
Block a user