feat / AEB-65 generate reports

This commit is contained in:
Timofey
2025-10-07 15:24:22 +03:00
parent 497cd7e292
commit 6e43a8afed
11 changed files with 289 additions and 33 deletions

View File

@@ -17,6 +17,7 @@ requests = "*"
pyjwt = "*"
drf-spectacular = "*"
pillow = "*"
reportlab = "*"
[dev-packages]

11
backend/Pipfile.lock generated
View File

@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "9818bd0afdf9b9b9884ad0f207ea2fa3485e448d2a80cd079d604018130ae088"
"sha256": "c74de9a446b92f720fd07b652e785a1e38dbca779487bb7ea2ae07c596c76b0f"
},
"pipfile-spec": 6,
"requires": {
@@ -560,6 +560,15 @@
"markers": "python_version >= '3.9'",
"version": "==0.36.2"
},
"reportlab": {
"hashes": [
"sha256:299b3b0534e7202bb94ed2ddcd7179b818dcda7de9d8518a57c85a58a1ebaadb",
"sha256:cb2f658b7f4a15be2cc68f7203aa67faef67213edd4f2d4bdd3eb20dab75a80d"
],
"index": "pypi",
"markers": "python_version >= '3.9' and python_version < '4'",
"version": "==4.4.4"
},
"requests": {
"hashes": [
"sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6",

View File

@@ -2,7 +2,7 @@ from django.urls import path
from .views.UserDataView import UserDataView
from .views.objects_views import ObjectView
from .views.sensors_views import SensorView
from .views.alert_views import AlertView
from .views.alert_views import AlertView, ReportView
from drf_spectacular.views import (
SpectacularAPIView,
SpectacularSwaggerView,
@@ -31,4 +31,6 @@ urlpatterns = [
path("get-alerts/", AlertView.as_view({'get': 'get_alerts'}), name="alerts"),
path("update-alert/<int:pk>/", AlertView.as_view({'patch': 'change_alert_status'}), name="update-alert"),
path("get-reports/", ReportView.as_view({'post': 'get_reports'}), name="reports"),
]

View File

@@ -1,13 +1,16 @@
from rest_framework import status, serializers
from rest_framework import status
from rest_framework.viewsets import ViewSet
from rest_framework.permissions import IsAuthenticated
from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiExample
from rest_framework.decorators import action
from rest_framework.response import Response
from django.http import HttpResponse
from api.account.serializers.alert_serializers import AlertSerializer
from sitemanagement.models import Alert
from api.utils.decorators import handle_exceptions
from api.utils.error_serializer import ErrorResponseSerializer
from api.utils.report_generators import generate_csv_report, generate_pdf_report
from django.utils import timezone
@extend_schema(tags=['Алерты'])
class AlertView(ViewSet):
@@ -79,3 +82,68 @@ class AlertView(ViewSet):
{"error": "Алерт не найден"},
status=status.HTTP_404_NOT_FOUND)
@extend_schema(tags=['Репорты'])
class ReportView(ViewSet):
# permission_classes = [IsAuthenticated]
@extend_schema(
summary="Генерация отчета",
description="Генерирует отчет в выбранном формате (PDF или CSV)",
request={'application/json': {'type': 'object', 'properties': {'report_format': {'type': 'string', 'enum': ['pdf', 'csv']}}}},
responses={
200: OpenApiResponse(response=AlertSerializer(many=True), description="Список репортов успешно получен",
examples=[OpenApiExample(
'Успешный ответ',
value=[{
"message" : "Отчет успешно сгенерирован"
}]
)]),
400: OpenApiResponse(
response=ErrorResponseSerializer,
description="Неверный формат",
examples=[OpenApiExample(
'Неверный формат',
value={"error": "Неверный формат"},
status_codes=['400']
)]
)
})
@action(detail=False, methods=['post'])
@handle_exceptions
def get_reports(self, request):
"""Генерация отчета в выбранном формате"""
report_format = request.data.get('report_format', '').lower()
if not report_format:
report_format = request.query_params.get('format', '').lower()
if report_format not in ["pdf", "csv"]:
return Response(
{"error": "Неверный формат. Допустимые значения: pdf, csv"},
status=status.HTTP_400_BAD_REQUEST
)
alerts = Alert.objects.select_related(
'sensor',
'sensor__signal_format',
'sensor_type',
'metric'
).prefetch_related(
'sensor__zones',
'sensor__zones__object'
).all()
# текущая дата для имени файла
timestamp = timezone.now().strftime("%Y%m%d_%H%M%S")
if report_format == "csv":
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = f'attachment; filename="alerts_report_{timestamp}.csv"'
response.write(generate_csv_report(alerts))
return response
else: # pdf
response = HttpResponse(content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="alerts_report_{timestamp}.pdf"'
response.write(generate_pdf_report(alerts))
return response

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,143 @@
import csv
from io import StringIO, BytesIO
from reportlab.lib import colors
from reportlab.lib.pagesizes import letter
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from django.utils import timezone
import os
FONT_PATH = os.path.join(os.path.dirname(__file__), 'fonts')
ARIAL_PATH = os.path.join(FONT_PATH, 'arialmt.ttf')
ARIAL_BOLD_PATH = os.path.join(FONT_PATH, 'arial_bolditalicmt.ttf')
if not os.path.exists(ARIAL_PATH) or not os.path.exists(ARIAL_BOLD_PATH):
raise FileNotFoundError(
f"Шрифты не найдены. Пожалуйста, убедитесь что файлы arialmt.ttf и arial_bolditalicmt.ttf "
f"находятся в директории {FONT_PATH}"
)
pdfmetrics.registerFont(TTFont('Arial', ARIAL_PATH))
pdfmetrics.registerFont(TTFont('Arial-Bold', ARIAL_BOLD_PATH))
styles = getSampleStyleSheet()
styles.add(ParagraphStyle(
name='CustomNormal',
fontName='Arial',
fontSize=10,
leading=12,
))
styles.add(ParagraphStyle(
name='CustomHeading1',
fontName='Arial-Bold',
fontSize=14,
leading=16,
))
def generate_csv_report(alerts):
"""Генерирует CSV отчет из списка алертов"""
output = StringIO()
writer = csv.writer(output)
# заголовки
headers = ['ID', 'Датчик', 'Объект', 'Значение', 'Тип датчика', 'Сообщение',
'Уровень важности', 'Дата создания', 'Обработан']
writer.writerow(headers)
# данные
for alert in alerts:
zone = alert.sensor.zones.first()
object_name = zone.object.title if zone else 'Не указан'
writer.writerow([
alert.id,
alert.sensor.name,
object_name,
f"{alert.metric.value} {alert.sensor.signal_format.unit if alert.sensor.signal_format else ''}" if alert.metric.value else alert.metric.raw_value,
alert.sensor_type.name,
alert.message,
alert.severity,
timezone.localtime(alert.created_at).strftime("%Y-%m-%d %H:%M:%S %Z"),
'Да' if alert.resolved else 'Нет'
])
return output.getvalue()
def generate_pdf_report(alerts):
"""Генерирует PDF отчет из списка алертов"""
buffer = BytesIO()
doc = SimpleDocTemplate(buffer, pagesize=letter)
elements = []
# заголовок
title = Paragraph(
f"Отчет по алертам (сгенерирован {timezone.localtime().strftime('%Y-%m-%d %H:%M %Z')})",
styles['CustomHeading1']
)
elements.append(title)
# данные для таблицы
headers = ['ID', 'Датчик', 'Объект', 'Значение', 'Тип датчика', 'Сообщение',
'Уровень', 'Дата создания', 'Обработан']
# оборачиваем заголовки в параграф
header_cells = [Paragraph(str(header), styles['CustomNormal']) for header in headers]
data = [header_cells]
for alert in alerts:
zone = alert.sensor.zones.first()
object_name = zone.object.title if zone else 'Не указан'
# оборачиваем каждую ячейку в параграф
row_data = [
Paragraph(str(alert.id), styles['CustomNormal']),
Paragraph(str(alert.sensor.name), styles['CustomNormal']),
Paragraph(str(object_name), styles['CustomNormal']),
Paragraph(
str(f"{alert.metric.value} {alert.sensor.signal_format.unit if alert.sensor.signal_format else ''}" if alert.metric.value else alert.metric.raw_value),
styles['CustomNormal']
),
Paragraph(str(alert.sensor_type.name), styles['CustomNormal']),
Paragraph(str(alert.message), styles['CustomNormal']),
Paragraph(str(alert.severity), styles['CustomNormal']),
Paragraph(timezone.localtime(alert.created_at).strftime("%Y-%m-%d %H:%M:%S %Z"), styles['CustomNormal']),
Paragraph('Да' if alert.resolved else 'Нет', styles['CustomNormal'])
]
data.append(row_data)
# создаем таблицу
table = Table(data)
table.setStyle(TableStyle([
# стиль заголовка
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#1b8755')),
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('FONTSIZE', (0, 0), (-1, 0), 11),
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
('TOPPADDING', (0, 0), (-1, 0), 12),
# стиль данных
('BACKGROUND', (0, 1), (-1, -1), colors.white),
('TEXTCOLOR', (0, 1), (-1, -1), colors.black),
('FONTSIZE', (0, 1), (-1, -1), 10),
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
('GRID', (0, 0), (-1, -1), 1, colors.black),
# отступы для всех ячеек
('LEFTPADDING', (0, 0), (-1, -1), 6),
('RIGHTPADDING', (0, 0), (-1, -1), 6),
('TOPPADDING', (0, 1), (-1, -1), 8),
('BOTTOMPADDING', (0, 1), (-1, -1), 8),
# чередующиеся цвета строк для лучшей читаемости
('BACKGROUND', (0, 1), (-1, -1), colors.white),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#f5f5f5')])
]))
elements.append(table)
doc.build(elements)
return buffer.getvalue()

View File

@@ -25,6 +25,7 @@ pytest-django==4.11.1
python-dotenv==1.1.1
PyYAML==6.0.3
referencing==0.36.2
reportlab==4.4.4
requests==2.32.5
rpds-py==0.27.1
sqlparse==0.5.3