Feature: system status (#5743)

This commit is contained in:
shamoon
2024-03-04 09:26:25 -08:00
committed by GitHub
parent 23ceb2a5ec
commit f6084acfc8
19 changed files with 1129 additions and 83 deletions

View File

@@ -0,0 +1,186 @@
import os
from pathlib import Path
from unittest import mock
from django.contrib.auth.models import User
from django.test import override_settings
from rest_framework import status
from rest_framework.test import APITestCase
from documents.classifier import DocumentClassifier
from documents.classifier import load_classifier
from paperless import version
class TestSystemStatus(APITestCase):
ENDPOINT = "/api/status/"
def setUp(self):
self.user = User.objects.create_superuser(
username="temp_admin",
)
def test_system_status(self):
"""
GIVEN:
- A user is logged in
WHEN:
- The user requests the system status
THEN:
- The response contains relevant system status information
"""
self.client.force_login(self.user)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["pngx_version"], version.__full_version_str__)
self.assertIsNotNone(response.data["server_os"])
self.assertEqual(response.data["install_type"], "bare-metal")
self.assertIsNotNone(response.data["storage"]["total"])
self.assertIsNotNone(response.data["storage"]["available"])
self.assertEqual(response.data["database"]["type"], "sqlite")
self.assertIsNotNone(response.data["database"]["url"])
self.assertEqual(response.data["database"]["status"], "OK")
self.assertIsNone(response.data["database"]["error"])
self.assertIsNotNone(response.data["database"]["migration_status"])
self.assertEqual(response.data["tasks"]["redis_url"], "redis://localhost:6379")
self.assertEqual(response.data["tasks"]["redis_status"], "ERROR")
self.assertIsNotNone(response.data["tasks"]["redis_error"])
def test_system_status_insufficient_permissions(self):
"""
GIVEN:
- A user is not logged in or does not have permissions
WHEN:
- The user requests the system status
THEN:
- The response contains a 401 status code or a 403 status code
"""
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
normal_user = User.objects.create_user(username="normal_user")
self.client.force_login(normal_user)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_system_status_container_detection(self):
"""
GIVEN:
- The application is running in a containerized environment
WHEN:
- The user requests the system status
THEN:
- The response contains the correct install type
"""
self.client.force_login(self.user)
os.environ["PNGX_CONTAINERIZED"] = "1"
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["install_type"], "docker")
os.environ["KUBERNETES_SERVICE_HOST"] = "http://localhost"
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.data["install_type"], "kubernetes")
@mock.patch("redis.Redis.execute_command")
def test_system_status_redis_ping(self, mock_ping):
"""
GIVEN:
- Redies ping returns True
WHEN:
- The user requests the system status
THEN:
- The response contains the correct redis status
"""
mock_ping.return_value = True
self.client.force_login(self.user)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["tasks"]["redis_status"], "OK")
@mock.patch("celery.app.control.Inspect.ping")
def test_system_status_celery_ping(self, mock_ping):
"""
GIVEN:
- Celery ping returns pong
WHEN:
- The user requests the system status
THEN:
- The response contains the correct celery status
"""
mock_ping.return_value = {"hostname": {"ok": "pong"}}
self.client.force_login(self.user)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["tasks"]["celery_status"], "OK")
@override_settings(INDEX_DIR=Path("/tmp/index"))
@mock.patch("whoosh.index.FileIndex.last_modified")
def test_system_status_index_ok(self, mock_last_modified):
"""
GIVEN:
- The index last modified time is set
WHEN:
- The user requests the system status
THEN:
- The response contains the correct index status
"""
mock_last_modified.return_value = 1707839087
self.client.force_login(self.user)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["tasks"]["index_status"], "OK")
self.assertIsNotNone(response.data["tasks"]["index_last_modified"])
@override_settings(INDEX_DIR="/tmp/index/")
@mock.patch("documents.index.open_index", autospec=True)
def test_system_status_index_error(self, mock_open_index):
"""
GIVEN:
- The index is not found
WHEN:
- The user requests the system status
THEN:
- The response contains the correct index status
"""
mock_open_index.return_value = None
mock_open_index.side_effect = Exception("Index error")
self.client.force_login(self.user)
response = self.client.get(self.ENDPOINT)
mock_open_index.assert_called_once()
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["tasks"]["index_status"], "ERROR")
self.assertIsNotNone(response.data["tasks"]["index_error"])
@override_settings(DATA_DIR="/tmp/does_not_exist/data/")
def test_system_status_classifier_ok(self):
"""
GIVEN:
- The classifier is found
WHEN:
- The user requests the system status
THEN:
- The response contains the correct classifier status
"""
load_classifier()
test_classifier = DocumentClassifier()
test_classifier.save()
self.client.force_login(self.user)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["tasks"]["classifier_status"], "OK")
self.assertIsNone(response.data["tasks"]["classifier_error"])
def test_system_status_classifier_error(self):
"""
GIVEN:
- The classifier is not found
WHEN:
- The user requests the system status
THEN:
- The response contains an error classifier status
"""
with override_settings(MODEL_FILE="does_not_exist"):
self.client.force_login(self.user)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["tasks"]["classifier_status"], "ERROR")
self.assertIsNotNone(response.data["tasks"]["classifier_error"])

View File

@@ -2,6 +2,7 @@ import itertools
import json
import logging
import os
import platform
import re
import tempfile
import urllib
@@ -13,8 +14,12 @@ from unicodedata import normalize
from urllib.parse import quote
import pathvalidate
from django.apps import apps
from django.conf import settings
from django.contrib.auth.models import User
from django.db import connections
from django.db.migrations.loader import MigrationLoader
from django.db.migrations.recorder import MigrationRecorder
from django.db.models import Case
from django.db.models import Count
from django.db.models import IntegerField
@@ -31,6 +36,7 @@ from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.timezone import make_aware
from django.utils.translation import get_language
from django.views import View
from django.views.decorators.cache import cache_control
@@ -40,6 +46,7 @@ from django.views.generic import TemplateView
from django_filters.rest_framework import DjangoFilterBackend
from langdetect import detect
from packaging import version as packaging_version
from redis import Redis
from rest_framework import parsers
from rest_framework.decorators import action
from rest_framework.exceptions import NotFound
@@ -61,6 +68,7 @@ from rest_framework.viewsets import ReadOnlyModelViewSet
from rest_framework.viewsets import ViewSet
from documents import bulk_edit
from documents import index
from documents.bulk_download import ArchiveOnlyStrategy
from documents.bulk_download import OriginalAndArchiveStrategy
from documents.bulk_download import OriginalsOnlyStrategy
@@ -138,6 +146,7 @@ from documents.serialisers import WorkflowTriggerSerializer
from documents.signals import document_updated
from documents.tasks import consume_file
from paperless import version
from paperless.celery import app as celery_app
from paperless.config import GeneralConfig
from paperless.db import GnuPG
from paperless.views import StandardPagination
@@ -1539,3 +1548,132 @@ class CustomFieldViewSet(ModelViewSet):
model = CustomField
queryset = CustomField.objects.all().order_by("-created")
class SystemStatusView(GenericAPIView, PassUserMixin):
permission_classes = (IsAuthenticated,)
def get(self, request, format=None):
if not request.user.has_perm("admin.view_logentry"):
return HttpResponseForbidden("Insufficient permissions")
current_version = version.__full_version_str__
install_type = "bare-metal"
if os.environ.get("KUBERNETES_SERVICE_HOST") is not None:
install_type = "kubernetes"
elif os.environ.get("PNGX_CONTAINERIZED") == "1":
install_type = "docker"
db_conn = connections["default"]
db_url = db_conn.settings_dict["NAME"]
db_error = None
try:
db_conn.ensure_connection()
db_status = "OK"
loader = MigrationLoader(connection=db_conn)
all_migrations = [f"{app}.{name}" for app, name in loader.graph.nodes]
applied_migrations = [
f"{m.app}.{m.name}"
for m in MigrationRecorder.Migration.objects.all().order_by("id")
]
except Exception as e: # pragma: no cover
applied_migrations = []
db_status = "ERROR"
logger.exception(f"System status error connecting to database: {e}")
db_error = "Error connecting to database, check logs for more detail."
media_stats = os.statvfs(settings.MEDIA_ROOT)
redis_url = settings._CHANNELS_REDIS_URL
redis_error = None
with Redis.from_url(url=redis_url) as client:
try:
client.ping()
redis_status = "OK"
except Exception as e:
redis_status = "ERROR"
logger.exception(f"System status error connecting to redis: {e}")
redis_error = "Error connecting to redis, check logs for more detail."
try:
celery_ping = celery_app.control.inspect().ping()
first_worker_ping = celery_ping[next(iter(celery_ping.keys()))]
if first_worker_ping["ok"] == "pong":
celery_active = "OK"
except Exception:
celery_active = "ERROR"
index_error = None
try:
ix = index.open_index()
index_status = "OK"
index_last_modified = make_aware(
datetime.fromtimestamp(ix.last_modified()),
)
except Exception as e:
index_status = "ERROR"
index_error = "Error opening index, check logs for more detail."
logger.exception(f"System status error opening index: {e}")
index_last_modified = None
classifier_error = None
try:
classifier = load_classifier()
if classifier is None:
raise Exception("Classifier not loaded")
classifier_status = "OK"
task_result_model = apps.get_model("django_celery_results", "taskresult")
result = (
task_result_model.objects.filter(
task_name="documents.tasks.train_classifier",
status="SUCCESS",
)
.order_by(
"-date_done",
)
.first()
)
classifier_last_trained = result.date_done if result else None
except Exception as e:
classifier_status = "ERROR"
classifier_last_trained = None
classifier_error = "Error loading classifier, check logs for more detail."
logger.exception(f"System status error loading classifier: {e}")
return Response(
{
"pngx_version": current_version,
"server_os": platform.platform(),
"install_type": install_type,
"storage": {
"total": media_stats.f_frsize * media_stats.f_blocks,
"available": media_stats.f_frsize * media_stats.f_bavail,
},
"database": {
"type": db_conn.vendor,
"url": db_url,
"status": db_status,
"error": db_error,
"migration_status": {
"latest_migration": applied_migrations[-1],
"unapplied_migrations": [
m for m in all_migrations if m not in applied_migrations
],
},
},
"tasks": {
"redis_url": redis_url,
"redis_status": redis_status,
"redis_error": redis_error,
"celery_status": celery_active,
"index_status": index_status,
"index_last_modified": index_last_modified,
"index_error": index_error,
"classifier_status": classifier_status,
"classifier_last_trained": classifier_last_trained,
"classifier_error": classifier_error,
},
},
)

View File

@@ -32,6 +32,7 @@ from documents.views import SharedLinkView
from documents.views import ShareLinkViewSet
from documents.views import StatisticsView
from documents.views import StoragePathViewSet
from documents.views import SystemStatusView
from documents.views import TagViewSet
from documents.views import TasksViewSet
from documents.views import UiSettingsView
@@ -147,6 +148,11 @@ urlpatterns = [
ProfileView.as_view(),
name="profile_view",
),
re_path(
"^status/",
SystemStatusView.as_view(),
name="system_status",
),
*api_router.urls,
],
),