diff --git a/backend/Pipfile b/backend/Pipfile index ff584bc..17eaf37 100644 --- a/backend/Pipfile +++ b/backend/Pipfile @@ -17,6 +17,7 @@ requests = "*" pyjwt = "*" drf-spectacular = "*" pillow = "*" +reportlab = "*" [dev-packages] diff --git a/backend/Pipfile.lock b/backend/Pipfile.lock index a70f31e..44a60e2 100644 --- a/backend/Pipfile.lock +++ b/backend/Pipfile.lock @@ -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", diff --git a/backend/api/account/urls.py b/backend/api/account/urls.py index d34c932..37c5e98 100644 --- a/backend/api/account/urls.py +++ b/backend/api/account/urls.py @@ -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//", AlertView.as_view({'patch': 'change_alert_status'}), name="update-alert"), + + path("get-reports/", ReportView.as_view({'post': 'get_reports'}), name="reports"), ] diff --git a/backend/api/account/views/alert_views.py b/backend/api/account/views/alert_views.py index c29fa81..2fe6374 100644 --- a/backend/api/account/views/alert_views.py +++ b/backend/api/account/views/alert_views.py @@ -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 \ No newline at end of file diff --git a/backend/api/utils/fonts/arial_bolditalicmt.ttf b/backend/api/utils/fonts/arial_bolditalicmt.ttf new file mode 100755 index 0000000..bb09708 Binary files /dev/null and b/backend/api/utils/fonts/arial_bolditalicmt.ttf differ diff --git a/backend/api/utils/fonts/arialmt.ttf b/backend/api/utils/fonts/arialmt.ttf new file mode 100755 index 0000000..a882d32 Binary files /dev/null and b/backend/api/utils/fonts/arialmt.ttf differ diff --git a/backend/api/utils/report_generators.py b/backend/api/utils/report_generators.py new file mode 100644 index 0000000..8e2db1d --- /dev/null +++ b/backend/api/utils/report_generators.py @@ -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() diff --git a/backend/requirements.txt b/backend/requirements.txt index a2b276e..7ea7417 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/frontend/app/(protected)/reports/page.tsx b/frontend/app/(protected)/reports/page.tsx index b2e999a..112910c 100644 --- a/frontend/app/(protected)/reports/page.tsx +++ b/frontend/app/(protected)/reports/page.tsx @@ -7,17 +7,18 @@ import useNavigationStore from '../../store/navigationStore' import ReportsList from '../../../components/reports/ReportsList' import ExportMenu from '../../../components/ui/ExportMenu' import detectorsData from '../../../data/detectors.json' +import axios from 'axios' const ReportsPage: React.FC = () => { const router = useRouter() const searchParams = useSearchParams() const { currentObject, setCurrentObject } = useNavigationStore() - + const urlObjectId = searchParams.get('objectId') const urlObjectTitle = searchParams.get('objectTitle') const objectId = currentObject.id || urlObjectId const objectTitle = currentObject.title || urlObjectTitle - + useEffect(() => { if (urlObjectId && urlObjectTitle && (!currentObject.id || currentObject.id !== urlObjectId)) { setCurrentObject(urlObjectId, urlObjectTitle) @@ -28,27 +29,60 @@ const ReportsPage: React.FC = () => { router.push('/dashboard') } - const handleExport = (format: 'csv' | 'pdf') => { - // TODO: добавить функционал по экспорту отчетов - console.log(`Exporting reports as ${format}`) + const handleExport = async (format: 'csv' | 'pdf') => { + try { + const response = await axios.post( + `${process.env.NEXT_PUBLIC_API_URL}/account/get-reports/`, + { report_format: format }, + { + responseType: 'blob', + headers: { + 'Content-Type': 'application/json', + }, + } + ) + + const contentDisposition = response.headers['content-disposition'] + const filenameMatch = contentDisposition?.match(/filename="(.+)"/) + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace(/T/g, '_') + const filename = filenameMatch ? filenameMatch[1] : `alerts_report_${timestamp}.${format}` + + const url = window.URL.createObjectURL(new Blob([response.data])) + const link = document.createElement('a') + link.href = url + link.download = filename + + document.body.appendChild(link) + link.click() + + document.body.removeChild(link) + window.URL.revokeObjectURL(url) + } catch (error) { + console.error('Error:', error) + } } - + return ( -
- - -
-
+
+ + +
+
-
- -
+ +
-
-

Отчеты по датчикам

- +
+

Отчеты по датчикам

+
- - + +
@@ -80,4 +111,4 @@ const ReportsPage: React.FC = () => { ) } -export default ReportsPage \ No newline at end of file +export default ReportsPage diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ceb8fc0..cfb3528 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,7 +11,7 @@ "@babylonjs/core": "^6.44.0", "@babylonjs/loaders": "^6.49.0", "@tanstack/react-query": "^5.85.5", - "axios": "^1.11.0", + "axios": "^1.12.2", "next": "^15.4.3", "next-auth": "^4.24.11", "react": "^19.1.0", @@ -3366,6 +3366,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", diff --git a/frontend/package.json b/frontend/package.json index 2901934..daf605a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,7 @@ "@babylonjs/core": "^6.44.0", "@babylonjs/loaders": "^6.49.0", "@tanstack/react-query": "^5.85.5", - "axios": "^1.11.0", + "axios": "^1.12.2", "next": "^15.4.3", "next-auth": "^4.24.11", "react": "^19.1.0",