feat / AEB-65 generate reports
This commit is contained in:
@@ -17,6 +17,7 @@ requests = "*"
|
|||||||
pyjwt = "*"
|
pyjwt = "*"
|
||||||
drf-spectacular = "*"
|
drf-spectacular = "*"
|
||||||
pillow = "*"
|
pillow = "*"
|
||||||
|
reportlab = "*"
|
||||||
|
|
||||||
[dev-packages]
|
[dev-packages]
|
||||||
|
|
||||||
|
|||||||
11
backend/Pipfile.lock
generated
11
backend/Pipfile.lock
generated
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"_meta": {
|
"_meta": {
|
||||||
"hash": {
|
"hash": {
|
||||||
"sha256": "9818bd0afdf9b9b9884ad0f207ea2fa3485e448d2a80cd079d604018130ae088"
|
"sha256": "c74de9a446b92f720fd07b652e785a1e38dbca779487bb7ea2ae07c596c76b0f"
|
||||||
},
|
},
|
||||||
"pipfile-spec": 6,
|
"pipfile-spec": 6,
|
||||||
"requires": {
|
"requires": {
|
||||||
@@ -560,6 +560,15 @@
|
|||||||
"markers": "python_version >= '3.9'",
|
"markers": "python_version >= '3.9'",
|
||||||
"version": "==0.36.2"
|
"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": {
|
"requests": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6",
|
"sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from django.urls import path
|
|||||||
from .views.UserDataView import UserDataView
|
from .views.UserDataView import UserDataView
|
||||||
from .views.objects_views import ObjectView
|
from .views.objects_views import ObjectView
|
||||||
from .views.sensors_views import SensorView
|
from .views.sensors_views import SensorView
|
||||||
from .views.alert_views import AlertView
|
from .views.alert_views import AlertView, ReportView
|
||||||
from drf_spectacular.views import (
|
from drf_spectacular.views import (
|
||||||
SpectacularAPIView,
|
SpectacularAPIView,
|
||||||
SpectacularSwaggerView,
|
SpectacularSwaggerView,
|
||||||
@@ -31,4 +31,6 @@ urlpatterns = [
|
|||||||
|
|
||||||
path("get-alerts/", AlertView.as_view({'get': 'get_alerts'}), name="alerts"),
|
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("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"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
from rest_framework import status, serializers
|
from rest_framework import status
|
||||||
from rest_framework.viewsets import ViewSet
|
from rest_framework.viewsets import ViewSet
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiExample
|
from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiExample
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
from django.http import HttpResponse
|
||||||
from api.account.serializers.alert_serializers import AlertSerializer
|
from api.account.serializers.alert_serializers import AlertSerializer
|
||||||
from sitemanagement.models import Alert
|
from sitemanagement.models import Alert
|
||||||
from api.utils.decorators import handle_exceptions
|
from api.utils.decorators import handle_exceptions
|
||||||
from api.utils.error_serializer import ErrorResponseSerializer
|
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=['Алерты'])
|
@extend_schema(tags=['Алерты'])
|
||||||
class AlertView(ViewSet):
|
class AlertView(ViewSet):
|
||||||
@@ -79,3 +82,68 @@ class AlertView(ViewSet):
|
|||||||
{"error": "Алерт не найден"},
|
{"error": "Алерт не найден"},
|
||||||
status=status.HTTP_404_NOT_FOUND)
|
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
|
||||||
BIN
backend/api/utils/fonts/arial_bolditalicmt.ttf
Executable file
BIN
backend/api/utils/fonts/arial_bolditalicmt.ttf
Executable file
Binary file not shown.
BIN
backend/api/utils/fonts/arialmt.ttf
Executable file
BIN
backend/api/utils/fonts/arialmt.ttf
Executable file
Binary file not shown.
143
backend/api/utils/report_generators.py
Normal file
143
backend/api/utils/report_generators.py
Normal 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()
|
||||||
@@ -25,6 +25,7 @@ pytest-django==4.11.1
|
|||||||
python-dotenv==1.1.1
|
python-dotenv==1.1.1
|
||||||
PyYAML==6.0.3
|
PyYAML==6.0.3
|
||||||
referencing==0.36.2
|
referencing==0.36.2
|
||||||
|
reportlab==4.4.4
|
||||||
requests==2.32.5
|
requests==2.32.5
|
||||||
rpds-py==0.27.1
|
rpds-py==0.27.1
|
||||||
sqlparse==0.5.3
|
sqlparse==0.5.3
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import useNavigationStore from '../../store/navigationStore'
|
|||||||
import ReportsList from '../../../components/reports/ReportsList'
|
import ReportsList from '../../../components/reports/ReportsList'
|
||||||
import ExportMenu from '../../../components/ui/ExportMenu'
|
import ExportMenu from '../../../components/ui/ExportMenu'
|
||||||
import detectorsData from '../../../data/detectors.json'
|
import detectorsData from '../../../data/detectors.json'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
const ReportsPage: React.FC = () => {
|
const ReportsPage: React.FC = () => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -28,9 +29,37 @@ const ReportsPage: React.FC = () => {
|
|||||||
router.push('/dashboard')
|
router.push('/dashboard')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExport = (format: 'csv' | 'pdf') => {
|
const handleExport = async (format: 'csv' | 'pdf') => {
|
||||||
// TODO: добавить функционал по экспорту отчетов
|
try {
|
||||||
console.log(`Exporting reports as ${format}`)
|
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 (
|
return (
|
||||||
@@ -39,16 +68,21 @@ const ReportsPage: React.FC = () => {
|
|||||||
activeItem={9} // Reports
|
activeItem={9} // Reports
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
<header className="bg-[#161824] border-b border-gray-700 px-6 py-4">
|
<header className="border-b border-gray-700 bg-[#161824] px-6 py-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
onClick={handleBackClick}
|
onClick={handleBackClick}
|
||||||
className="text-gray-400 hover:text-white transition-colors"
|
className="text-gray-400 transition-colors hover:text-white"
|
||||||
aria-label="Назад к дашборду"
|
aria-label="Назад к дашборду"
|
||||||
>
|
>
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 19l-7-7 7-7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<nav className="flex items-center gap-2 text-sm">
|
<nav className="flex items-center gap-2 text-sm">
|
||||||
@@ -61,18 +95,15 @@ const ReportsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex-1 p-6 overflow-auto">
|
<div className="flex-1 overflow-auto p-6">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
<h1 className="text-white text-2xl font-semibold">Отчеты по датчикам</h1>
|
<h1 className="text-2xl font-semibold text-white">Отчеты по датчикам</h1>
|
||||||
|
|
||||||
<ExportMenu onExport={handleExport} />
|
<ExportMenu onExport={handleExport} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ReportsList
|
<ReportsList objectId={objectId || undefined} detectorsData={detectorsData} />
|
||||||
objectId={objectId || undefined}
|
|
||||||
detectorsData={detectorsData}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
3
frontend/package-lock.json
generated
3
frontend/package-lock.json
generated
@@ -11,7 +11,7 @@
|
|||||||
"@babylonjs/core": "^6.44.0",
|
"@babylonjs/core": "^6.44.0",
|
||||||
"@babylonjs/loaders": "^6.49.0",
|
"@babylonjs/loaders": "^6.49.0",
|
||||||
"@tanstack/react-query": "^5.85.5",
|
"@tanstack/react-query": "^5.85.5",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.12.2",
|
||||||
"next": "^15.4.3",
|
"next": "^15.4.3",
|
||||||
"next-auth": "^4.24.11",
|
"next-auth": "^4.24.11",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
@@ -3366,6 +3366,7 @@
|
|||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"@babylonjs/core": "^6.44.0",
|
"@babylonjs/core": "^6.44.0",
|
||||||
"@babylonjs/loaders": "^6.49.0",
|
"@babylonjs/loaders": "^6.49.0",
|
||||||
"@tanstack/react-query": "^5.85.5",
|
"@tanstack/react-query": "^5.85.5",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.12.2",
|
||||||
"next": "^15.4.3",
|
"next": "^15.4.3",
|
||||||
"next-auth": "^4.24.11",
|
"next-auth": "^4.24.11",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user