mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-12-24 02:05:48 -06:00
Compare commits
6 Commits
005ef4fce6
...
69514d8d70
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69514d8d70 | ||
|
|
dd6f7fad32 | ||
|
|
c5ad148dc7 | ||
|
|
b12f1e757c | ||
|
|
0cbab1ae80 | ||
|
|
0219df5b67 |
@@ -32,7 +32,7 @@ RUN set -eux \
|
||||
# Purpose: Installs s6-overlay and rootfs
|
||||
# Comments:
|
||||
# - Don't leave anything extra in here either
|
||||
FROM ghcr.io/astral-sh/uv:0.9.7-python3.12-bookworm-slim AS s6-overlay-base
|
||||
FROM ghcr.io/astral-sh/uv:0.9.9-python3.12-bookworm-slim AS s6-overlay-base
|
||||
|
||||
WORKDIR /usr/src/s6
|
||||
|
||||
|
||||
@@ -747,7 +747,7 @@ export class FilterEditorComponent
|
||||
) {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_TITLE_CONTENT,
|
||||
value: this._textFilter,
|
||||
value: this._textFilter.trim(),
|
||||
})
|
||||
}
|
||||
if (this._textFilter && this.textFilterTarget == TEXT_FILTER_TARGET_TITLE) {
|
||||
@@ -805,7 +805,7 @@ export class FilterEditorComponent
|
||||
) {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_FULLTEXT_QUERY,
|
||||
value: this._textFilter,
|
||||
value: this._textFilter.trim(),
|
||||
})
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -160,6 +160,7 @@ class InboxFilter(Filter):
|
||||
@extend_schema_field(serializers.CharField)
|
||||
class TitleContentFilter(Filter):
|
||||
def filter(self, qs, value):
|
||||
value = value.strip() if isinstance(value, str) else value
|
||||
if value:
|
||||
return qs.filter(Q(title__icontains=value) | Q(content__icontains=value))
|
||||
else:
|
||||
@@ -214,6 +215,7 @@ class CustomFieldFilterSet(FilterSet):
|
||||
@extend_schema_field(serializers.CharField)
|
||||
class CustomFieldsFilter(Filter):
|
||||
def filter(self, qs, value):
|
||||
value = value.strip() if isinstance(value, str) else value
|
||||
if value:
|
||||
fields_with_matching_selects = CustomField.objects.filter(
|
||||
extra_data__icontains=value,
|
||||
@@ -244,6 +246,7 @@ class CustomFieldsFilter(Filter):
|
||||
|
||||
class MimeTypeFilter(Filter):
|
||||
def filter(self, qs, value):
|
||||
value = value.strip() if isinstance(value, str) else value
|
||||
if value:
|
||||
return qs.filter(mime_type__icontains=value)
|
||||
else:
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from email import message_from_bytes
|
||||
from typing import TYPE_CHECKING
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMessage
|
||||
from filelock import FileLock
|
||||
|
||||
from documents.data_models import ConsumableDocument
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from documents.models import Document
|
||||
@dataclass(frozen=True)
|
||||
class EmailAttachment:
|
||||
path: Path
|
||||
mime_type: str
|
||||
friendly_name: str
|
||||
|
||||
|
||||
def send_email(
|
||||
subject: str,
|
||||
body: str,
|
||||
to: list[str],
|
||||
attachments: list[Document | ConsumableDocument],
|
||||
*,
|
||||
use_archive: bool,
|
||||
attachments: list[EmailAttachment],
|
||||
) -> int:
|
||||
"""
|
||||
Send an email with attachments.
|
||||
@@ -28,8 +29,7 @@ def send_email(
|
||||
subject: Email subject
|
||||
body: Email body text
|
||||
to: List of recipient email addresses
|
||||
attachments: List of documents to attach (the list may be empty)
|
||||
use_archive: Whether to attach archive versions when available
|
||||
attachments: List of attachments
|
||||
|
||||
Returns:
|
||||
Number of emails sent
|
||||
@@ -46,47 +46,41 @@ def send_email(
|
||||
|
||||
# Something could be renaming the file concurrently so it can't be attached
|
||||
with FileLock(settings.MEDIA_LOCK):
|
||||
for document in attachments:
|
||||
if isinstance(document, ConsumableDocument):
|
||||
attachment_path = document.original_file
|
||||
friendly_filename = document.original_file.name
|
||||
else:
|
||||
attachment_path = (
|
||||
document.archive_path
|
||||
if use_archive and document.has_archive_version
|
||||
else document.source_path
|
||||
)
|
||||
friendly_filename = _get_unique_filename(
|
||||
document,
|
||||
used_filenames,
|
||||
archive=use_archive and document.has_archive_version,
|
||||
)
|
||||
used_filenames.add(friendly_filename)
|
||||
for attachment in attachments:
|
||||
filename = _get_unique_filename(
|
||||
attachment.friendly_name,
|
||||
used_filenames,
|
||||
)
|
||||
used_filenames.add(filename)
|
||||
|
||||
with attachment_path.open("rb") as f:
|
||||
with attachment.path.open("rb") as f:
|
||||
content = f.read()
|
||||
if document.mime_type == "message/rfc822":
|
||||
if attachment.mime_type == "message/rfc822":
|
||||
# See https://forum.djangoproject.com/t/using-emailmessage-with-an-attached-email-file-crashes-due-to-non-ascii/37981
|
||||
content = message_from_bytes(content)
|
||||
|
||||
email.attach(
|
||||
filename=friendly_filename,
|
||||
filename=filename,
|
||||
content=content,
|
||||
mimetype=document.mime_type,
|
||||
mimetype=attachment.mime_type,
|
||||
)
|
||||
|
||||
return email.send()
|
||||
|
||||
|
||||
def _get_unique_filename(doc: Document, used_names: set[str], *, archive: bool) -> str:
|
||||
def _get_unique_filename(friendly_name: str, used_names: set[str]) -> str:
|
||||
"""
|
||||
Constructs a unique friendly filename for the given document.
|
||||
Constructs a unique friendly filename for the given document, append a counter if needed.
|
||||
"""
|
||||
if friendly_name not in used_names:
|
||||
return friendly_name
|
||||
|
||||
The filename might not be unique enough, so a counter is appended if needed.
|
||||
"""
|
||||
counter = 0
|
||||
stem = Path(friendly_name).stem
|
||||
suffix = "".join(Path(friendly_name).suffixes)
|
||||
|
||||
counter = 1
|
||||
while True:
|
||||
filename = doc.get_public_filename(archive=archive, counter=counter)
|
||||
filename = f"{stem}_{counter:02}{suffix}"
|
||||
if filename not in used_names:
|
||||
return filename
|
||||
counter += 1
|
||||
|
||||
@@ -31,11 +31,10 @@ from guardian.shortcuts import remove_perm
|
||||
|
||||
from documents import matching
|
||||
from documents.caching import clear_document_caches
|
||||
from documents.data_models import ConsumableDocument
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.file_handling import create_source_path_directory
|
||||
from documents.file_handling import delete_empty_directories
|
||||
from documents.file_handling import generate_unique_filename
|
||||
from documents.mail import EmailAttachment
|
||||
from documents.mail import send_email
|
||||
from documents.models import Correspondent
|
||||
from documents.models import CustomField
|
||||
@@ -57,6 +56,7 @@ from documents.templating.workflows import parse_w_workflow_placeholders
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from documents.classifier import DocumentClassifier
|
||||
from documents.data_models import ConsumableDocument
|
||||
from documents.data_models import DocumentMetadataOverrides
|
||||
|
||||
logger = logging.getLogger("paperless.handlers")
|
||||
@@ -1095,7 +1095,9 @@ def run_workflows(
|
||||
|
||||
if not use_overrides:
|
||||
title = document.title
|
||||
doc_url = f"{settings.PAPERLESS_URL}/documents/{document.pk}/"
|
||||
doc_url = (
|
||||
f"{settings.PAPERLESS_URL}{settings.BASE_URL}documents/{document.pk}/"
|
||||
)
|
||||
correspondent = (
|
||||
document.correspondent.name if document.correspondent else ""
|
||||
)
|
||||
@@ -1163,28 +1165,41 @@ def run_workflows(
|
||||
else ""
|
||||
)
|
||||
try:
|
||||
attachments = []
|
||||
attachments: list[EmailAttachment] = []
|
||||
if action.email.include_document:
|
||||
attachment: EmailAttachment | None = None
|
||||
if trigger_type in [
|
||||
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||
WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
||||
]:
|
||||
# Updated and scheduled can pass the document directly
|
||||
attachments = [document]
|
||||
] and isinstance(document, Document):
|
||||
friendly_name = (
|
||||
Path(current_filename).name
|
||||
if current_filename
|
||||
else document.source_path.name
|
||||
)
|
||||
attachment = EmailAttachment(
|
||||
path=document.source_path,
|
||||
mime_type=document.mime_type,
|
||||
friendly_name=friendly_name,
|
||||
)
|
||||
elif original_file:
|
||||
# For consumed and added document is not yet saved, so pass the original file
|
||||
attachments = [
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ApiUpload,
|
||||
original_file=original_file,
|
||||
),
|
||||
]
|
||||
friendly_name = (
|
||||
Path(current_filename).name
|
||||
if current_filename
|
||||
else original_file.name
|
||||
)
|
||||
attachment = EmailAttachment(
|
||||
path=original_file,
|
||||
mime_type=document.mime_type,
|
||||
friendly_name=friendly_name,
|
||||
)
|
||||
if attachment:
|
||||
attachments = [attachment]
|
||||
n_messages = send_email(
|
||||
subject=subject,
|
||||
body=body,
|
||||
to=action.email.to.split(","),
|
||||
attachments=attachments,
|
||||
use_archive=False,
|
||||
)
|
||||
logger.debug(
|
||||
f"Sent {n_messages} notification email(s) to {action.email.to}",
|
||||
@@ -1199,7 +1214,9 @@ def run_workflows(
|
||||
def webhook_action():
|
||||
if not use_overrides:
|
||||
title = document.title
|
||||
doc_url = f"{settings.PAPERLESS_URL}/documents/{document.pk}/"
|
||||
doc_url = (
|
||||
f"{settings.PAPERLESS_URL}{settings.BASE_URL}documents/{document.pk}/"
|
||||
)
|
||||
correspondent = (
|
||||
document.correspondent.name if document.correspondent else ""
|
||||
)
|
||||
|
||||
@@ -941,6 +941,23 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
results = response.data["results"]
|
||||
self.assertEqual(len(results), 0)
|
||||
|
||||
def test_documents_title_content_filter_strips_boundary_whitespace(self):
|
||||
doc = Document.objects.create(
|
||||
title="Testwort",
|
||||
content="",
|
||||
checksum="A",
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
"/api/documents/",
|
||||
{"title_content": " Testwort "},
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
results = response.data["results"]
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0]["id"], doc.id)
|
||||
|
||||
def test_document_permissions_filters(self):
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
@@ -8,8 +8,10 @@ from typing import TYPE_CHECKING
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import User
|
||||
from django.core import mail
|
||||
from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
from guardian.shortcuts import assign_perm
|
||||
@@ -21,6 +23,8 @@ from pytest_httpx import HTTPXMock
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from documents.file_handling import create_source_path_directory
|
||||
from documents.file_handling import generate_unique_filename
|
||||
from documents.signals.handlers import run_workflows
|
||||
from documents.signals.handlers import send_webhook
|
||||
|
||||
@@ -2989,6 +2993,70 @@ class TestWorkflows(
|
||||
|
||||
mock_email_send.assert_called_once()
|
||||
|
||||
@override_settings(
|
||||
PAPERLESS_EMAIL_HOST="localhost",
|
||||
EMAIL_ENABLED=True,
|
||||
PAPERLESS_URL="http://localhost:8000",
|
||||
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
|
||||
)
|
||||
def test_workflow_email_attachment_uses_storage_filename(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Document updated workflow with include document action
|
||||
- Document stored with formatted storage-path filename
|
||||
WHEN:
|
||||
- Workflow sends an email
|
||||
THEN:
|
||||
- Attachment filename matches the stored filename
|
||||
"""
|
||||
|
||||
trigger = WorkflowTrigger.objects.create(
|
||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||
)
|
||||
email_action = WorkflowActionEmail.objects.create(
|
||||
subject="Test Notification: {doc_title}",
|
||||
body="Test message: {doc_url}",
|
||||
to="me@example.com",
|
||||
include_document=True,
|
||||
)
|
||||
action = WorkflowAction.objects.create(
|
||||
type=WorkflowAction.WorkflowActionType.EMAIL,
|
||||
email=email_action,
|
||||
)
|
||||
workflow = Workflow.objects.create(
|
||||
name="Workflow attachment filename",
|
||||
order=0,
|
||||
)
|
||||
workflow.triggers.add(trigger)
|
||||
workflow.actions.add(action)
|
||||
workflow.save()
|
||||
|
||||
storage_path = StoragePath.objects.create(
|
||||
name="Fancy Path",
|
||||
path="formatted/{{ document.pk }}/{{ title }}",
|
||||
)
|
||||
doc = Document.objects.create(
|
||||
title="workflow doc",
|
||||
correspondent=self.c,
|
||||
checksum="workflow-email-attachment",
|
||||
mime_type="application/pdf",
|
||||
storage_path=storage_path,
|
||||
original_filename="workflow-orig.pdf",
|
||||
)
|
||||
|
||||
# eg what happens in update_filename_and_move_files
|
||||
generated = generate_unique_filename(doc)
|
||||
destination = (settings.ORIGINALS_DIR / generated).resolve()
|
||||
create_source_path_directory(destination)
|
||||
shutil.copy(self.SAMPLE_DIR / "simple.pdf", destination)
|
||||
Document.objects.filter(pk=doc.pk).update(filename=generated.as_posix())
|
||||
|
||||
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
||||
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
attachment_names = [att[0] for att in mail.outbox[0].attachments]
|
||||
self.assertEqual(attachment_names, [Path(generated).name])
|
||||
|
||||
@override_settings(
|
||||
EMAIL_ENABLED=False,
|
||||
)
|
||||
@@ -3144,6 +3212,8 @@ class TestWorkflows(
|
||||
|
||||
@override_settings(
|
||||
PAPERLESS_URL="http://localhost:8000",
|
||||
PAPERLESS_FORCE_SCRIPT_NAME="/paperless",
|
||||
BASE_URL="/paperless/",
|
||||
)
|
||||
@mock.patch("documents.signals.handlers.send_webhook.delay")
|
||||
def test_workflow_webhook_action_body(self, mock_post):
|
||||
@@ -3195,7 +3265,7 @@ class TestWorkflows(
|
||||
|
||||
mock_post.assert_called_once_with(
|
||||
url="http://paperless-ngx.com",
|
||||
data=f"Test message: http://localhost:8000/documents/{doc.id}/",
|
||||
data=f"Test message: http://localhost:8000/paperless/documents/{doc.id}/",
|
||||
headers={},
|
||||
files=None,
|
||||
as_json=False,
|
||||
|
||||
@@ -120,6 +120,7 @@ from documents.filters import PaperlessTaskFilterSet
|
||||
from documents.filters import ShareLinkFilterSet
|
||||
from documents.filters import StoragePathFilterSet
|
||||
from documents.filters import TagFilterSet
|
||||
from documents.mail import EmailAttachment
|
||||
from documents.mail import send_email
|
||||
from documents.matching import match_correspondents
|
||||
from documents.matching import match_document_types
|
||||
@@ -1216,12 +1217,28 @@ class DocumentViewSet(
|
||||
return HttpResponseForbidden("Insufficient permissions")
|
||||
|
||||
try:
|
||||
attachments: list[EmailAttachment] = []
|
||||
for doc in documents:
|
||||
attachment_path = (
|
||||
doc.archive_path
|
||||
if use_archive_version and doc.has_archive_version
|
||||
else doc.source_path
|
||||
)
|
||||
attachments.append(
|
||||
EmailAttachment(
|
||||
path=attachment_path,
|
||||
mime_type=doc.mime_type,
|
||||
friendly_name=doc.get_public_filename(
|
||||
archive=use_archive_version and doc.has_archive_version,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
send_email(
|
||||
subject=subject,
|
||||
body=message,
|
||||
to=addresses,
|
||||
attachments=documents,
|
||||
use_archive=use_archive_version,
|
||||
attachments=attachments,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
@@ -1863,7 +1880,7 @@ class SearchAutoCompleteView(GenericAPIView):
|
||||
user = self.request.user if hasattr(self.request, "user") else None
|
||||
|
||||
if "term" in request.query_params:
|
||||
term = request.query_params["term"]
|
||||
term = request.query_params["term"].strip()
|
||||
else:
|
||||
return HttpResponseBadRequest("Term required")
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-28 18:06+0000\n"
|
||||
"POT-Creation-Date: 2025-11-14 16:09+0000\n"
|
||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
@@ -21,39 +21,39 @@ msgstr ""
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:392
|
||||
#: documents/filters.py:395
|
||||
msgid "Value must be valid JSON."
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:411
|
||||
#: documents/filters.py:414
|
||||
msgid "Invalid custom field query expression"
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:421
|
||||
#: documents/filters.py:424
|
||||
msgid "Invalid expression list. Must be nonempty."
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:442
|
||||
#: documents/filters.py:445
|
||||
msgid "Invalid logical operator {op!r}"
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:456
|
||||
#: documents/filters.py:459
|
||||
msgid "Maximum number of query conditions exceeded."
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:521
|
||||
#: documents/filters.py:524
|
||||
msgid "{name!r} is not a valid custom field."
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:558
|
||||
#: documents/filters.py:561
|
||||
msgid "{data_type} does not support query expr {expr!r}."
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:666 documents/models.py:135
|
||||
#: documents/filters.py:669 documents/models.py:135
|
||||
msgid "Maximum nesting depth exceeded."
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:851
|
||||
#: documents/filters.py:854
|
||||
msgid "Custom field not found"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
@@ -53,14 +54,33 @@ class TestUrlCanary:
|
||||
Verify certain URLs are still available so testing is valid still
|
||||
"""
|
||||
|
||||
# Wikimedia rejects requests without a browser-like User-Agent header and returns 403.
|
||||
_WIKIMEDIA_HEADERS = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/123.0.0.0 Safari/537.36"
|
||||
),
|
||||
}
|
||||
@classmethod
|
||||
def _fetch_wikimedia(cls, url: str) -> httpx.Response:
|
||||
"""
|
||||
Wikimedia occasionally throttles automated requests (HTTP 429). Retry a few
|
||||
times with a short backoff so the tests stay stable, and skip if throttling
|
||||
persists.
|
||||
"""
|
||||
last_resp: httpx.Response | None = None
|
||||
# Wikimedia rejects requests without a browser-like User-Agent header and returns 403.
|
||||
headers = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/123.0.0.0 Safari/537.36"
|
||||
),
|
||||
}
|
||||
for delay in (0, 1, 2):
|
||||
resp = httpx.get(url, headers=headers, timeout=30.0)
|
||||
if resp.status_code != httpx.codes.TOO_MANY_REQUESTS:
|
||||
return resp
|
||||
last_resp = resp
|
||||
time.sleep(delay)
|
||||
|
||||
pytest.skip(
|
||||
"Wikimedia throttled the canary request with HTTP 429; try rerunning later.",
|
||||
)
|
||||
return last_resp # pragma: no cover
|
||||
|
||||
def test_online_image_exception_on_not_available(self):
|
||||
"""
|
||||
@@ -76,11 +96,10 @@ class TestUrlCanary:
|
||||
whether this image stays online forever, so here we check if we can detect if is not
|
||||
available anymore.
|
||||
"""
|
||||
resp = self._fetch_wikimedia(
|
||||
"https://upload.wikimedia.org/wikipedia/en/f/f7/nonexistent.png",
|
||||
)
|
||||
with pytest.raises(httpx.HTTPStatusError) as exec_info:
|
||||
resp = httpx.get(
|
||||
"https://upload.wikimedia.org/wikipedia/en/f/f7/nonexistent.png",
|
||||
headers=self._WIKIMEDIA_HEADERS,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
assert exec_info.value.response.status_code == httpx.codes.NOT_FOUND
|
||||
@@ -100,9 +119,8 @@ class TestUrlCanary:
|
||||
"""
|
||||
|
||||
# Now check the URL used in samples/sample.html
|
||||
resp = httpx.get(
|
||||
resp = self._fetch_wikimedia(
|
||||
"https://upload.wikimedia.org/wikipedia/en/f/f7/RickRoll.png",
|
||||
headers=self._WIKIMEDIA_HEADERS,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user