New api and zone management; highligh occlusion and highlighAll functionality; improved search in reports and alerts history + autofill; refactored alert panel

This commit is contained in:
iv_vuytsik
2025-12-25 03:10:21 +03:00
parent 31030f2997
commit ce7e39debf
36 changed files with 1562 additions and 472 deletions

View File

@@ -9,10 +9,11 @@ class AlertSerializer(serializers.ModelSerializer):
object = serializers.SerializerMethodField()
metric_value = serializers.SerializerMethodField()
detector_type = serializers.SerializerMethodField()
detector_id = serializers.SerializerMethodField()
class Meta:
model = Alert
fields = ('id', 'name', 'object', 'metric_value', 'detector_type', 'message', 'severity', 'created_at', 'resolved')
fields = ('id', 'name', 'object', 'metric_value', 'detector_type', 'detector_id', 'message', 'severity', 'created_at', 'resolved')
@extend_schema_field(OpenApiTypes.STR)
def get_name(self, obj) -> str:
@@ -38,3 +39,9 @@ class AlertSerializer(serializers.ModelSerializer):
if sensor_type is None:
return ''
return (getattr(sensor_type, 'code', '') or '').upper()
@extend_schema_field(OpenApiTypes.STR)
def get_detector_id(self, obj) -> str:
if hasattr(obj, 'sensor') and obj.sensor:
return obj.sensor.name or f"{obj.sensor.sensor_type.code}-{obj.sensor.id}"
return ""

View File

@@ -15,7 +15,7 @@ class ZoneSerializer(serializers.ModelSerializer):
class Meta:
model = Zone
fields = ('id', 'name', 'sensors')
fields = ('id', 'name', 'floor', 'image_path', 'model_path', 'order', 'sensors')
class ObjectSerializer(serializers.ModelSerializer):
zones = ZoneSerializer(many=True, read_only=True)

View File

@@ -4,6 +4,7 @@ from .views.objects_views import ObjectView
from .views.sensors_views import SensorView
from .views.alert_views import AlertView, ReportView
from .views.dashboard_views import DashboardView
from .views.zones_views import ZoneView
from drf_spectacular.views import (
SpectacularAPIView,
SpectacularSwaggerView,
@@ -36,4 +37,6 @@ urlpatterns = [
path("get-reports/", ReportView.as_view({'post': 'get_reports'}), name="reports"),
path("get-dashboard/", DashboardView.as_view(), name="dashboard"),
path("get-zones/", ZoneView.as_view(), name="zones"),
]

View File

@@ -116,7 +116,11 @@ class ReportView(ViewSet):
@extend_schema(
summary="Генерация отчета",
description="Генерирует отчет в выбранном формате (PDF или CSV)",
request={'application/json': {'type': 'object', 'properties': {'report_format': {'type': 'string', 'enum': ['pdf', 'csv']}}}},
request={'application/json': {'type': 'object', 'properties': {
'report_format': {'type': 'string', 'enum': ['pdf', 'csv']},
'hours': {'type': 'integer', 'description': 'Количество часов для фильтрации по времени'},
'detector_ids': {'type': 'array', 'items': {'type': 'string'}, 'description': 'Список ID датчиков для фильтрации (может быть числом или строкой вида "GLE-79")'}
}}},
methods=['POST'],
responses={
200: OpenApiResponse(
@@ -153,6 +157,10 @@ class ReportView(ViewSet):
status=status.HTTP_400_BAD_REQUEST
)
# Получаем параметры фильтрации
hours = request.data.get('hours')
detector_ids = request.data.get('detector_ids', [])
alerts = Alert.objects.select_related(
'sensor',
'sensor__signal_format',
@@ -163,6 +171,35 @@ class ReportView(ViewSet):
'sensor__zones__object'
).all()
# Фильтрация по времени (если указано количество часов)
if hours and isinstance(hours, (int, float)) and hours > 0:
from datetime import timedelta
cutoff_time = timezone.now() - timedelta(hours=hours)
alerts = alerts.filter(created_at__gte=cutoff_time)
# Фильтрация по датчикам (если указаны ID)
if detector_ids and isinstance(detector_ids, list) and len(detector_ids) > 0:
# Конвертируем строковые ID в числовые ID базы данных
numeric_ids = []
for detector_id in detector_ids:
if isinstance(detector_id, (int, float)):
numeric_ids.append(int(detector_id))
elif isinstance(detector_id, str):
# Парсим строковые ID вида "GLE-79" или "GA-123"
try:
if '-' in detector_id:
# Формат "TYPE-ID" - извлекаем числовую часть
numeric_ids.append(int(detector_id.split('-')[-1]))
else:
# Предполагаем, что это просто число в строковом формате
numeric_ids.append(int(detector_id))
except (ValueError, IndexError):
# Пропускаем некорректные ID
continue
if numeric_ids:
alerts = alerts.filter(sensor_id__in=numeric_ids)
# текущая дата для имени файла
timestamp = timezone.now().strftime("%Y%m%d_%H%M%S")

View File

@@ -60,8 +60,9 @@ class DashboardView(APIView):
if time_period not in ['720', '168', '72', '24']:
return Response(
{"error": "Неверный период. Допустимые значения: 720, 168, 72, 24"},
status=status.HTTP_400_BAD_REQUEST
- {"error": "Неверный период. Допустимые значения: 720, 168, 72, 24"},
+ {"error": "Неверный период. Допустимые значения: 24, 72, 168, 720"},
status=status.HTTP_400_BAD_REQUEST
)
# определяем начальную дату

View File

@@ -0,0 +1,66 @@
from rest_framework import status
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiExample
from api.account.serializers.objects_serializers import ZoneSerializer
from sitemanagement.models import Zone
from api.utils.decorators import handle_exceptions
@extend_schema(tags=['Зоны'])
class ZoneView(APIView):
permission_classes = [IsAuthenticated]
serializer_class = ZoneSerializer
@extend_schema(
summary="Получение зон для указанного объекта",
description="Возвращает список зон для объекта по query-параметру objectId",
responses={
200: OpenApiResponse(
response=ZoneSerializer,
description="Зоны успешно получены",
examples=[
OpenApiExample(
'Успешный ответ',
value=[
{
"id": 1,
"name": "Зона 1",
"floor": 1,
"image_path": "test_image_2.png",
"model_path": "/static-models/AerBIM-Monitor_ASM-HTViewer_Expo2017Astana_20250908_L_+76190.glb",
"order": 0,
"sensors": [
{
"id": 10,
"name": "Датчик 1",
"serial_number": "GA-123",
"sensor_type": "Тип датчика 1"
}
]
}
]
)
]
)
}
)
@handle_exceptions
def get(self, request):
"""Получение всех зон для указанного объекта"""
object_id = request.query_params.get('objectId') or request.query_params.get('object_id') or request.query_params.get('object')
if not object_id:
return Response({"error": "objectId query parameter is required"}, status=status.HTTP_400_BAD_REQUEST)
try:
zones = Zone.objects.filter(object_id=object_id).prefetch_related(
'sensors',
'sensors__sensor_type'
).order_by('order', 'name')
serializer = ZoneSerializer(zones, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
except Zone.DoesNotExist:
return Response({"error": "Зоны не найдены"}, status=status.HTTP_404_NOT_FOUND)

View File

@@ -76,9 +76,9 @@ class ObjectAdmin(admin.ModelAdmin):
@admin.register(Zone)
class ZoneAdmin(admin.ModelAdmin):
list_display = ('object', 'floor', 'name')
list_filter = ('object', 'floor')
search_fields = ('object__title', 'name')
list_display = ('object', 'floor', 'order', 'name', 'image_path', 'model_path')
list_filter = ('object', 'floor', 'order')
search_fields = ('object__title', 'name', 'image_path', 'model_path')
list_per_page = 10
list_max_show_all = 100
list_display_links = ('object',)

View File

@@ -0,0 +1,32 @@
# Generated by Django 5.2.5 on 2025-12-22 17:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sitemanagement', '0007_alter_zone_options_zone_floor'),
]
operations = [
migrations.AlterModelOptions(
name='zone',
options={'ordering': ['object', 'floor', 'order', 'name'], 'verbose_name': 'Зона', 'verbose_name_plural': 'Зоны'},
),
migrations.AddField(
model_name='zone',
name='image_path',
field=models.CharField(blank=True, default='test_image.png', help_text="Например 'public/images/test_image.png' или имя файла из public/images", max_length=255, verbose_name='Путь к изображению'),
),
migrations.AddField(
model_name='zone',
name='model_path',
field=models.CharField(blank=True, help_text="Например 'frontend/assets/big-models/model.glb' или относительный путь к модели", max_length=255, null=True, verbose_name='Путь к 3D модели'),
),
migrations.AddField(
model_name='zone',
name='order',
field=models.PositiveSmallIntegerField(default=0, verbose_name='Порядок'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.5 on 2025-12-22 17:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sitemanagement', '0008_alter_zone_options_zone_image_path_zone_model_path_and_more'),
]
operations = [
migrations.AlterField(
model_name='zone',
name='image_path',
field=models.CharField(blank=True, default='test_image.png', help_text="Например 'public/images/test_image.png' или имя файла из public/images", max_length=255, verbose_name='Путь к изображению'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.5 on 2025-12-24 19:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sitemanagement', '0009_alter_zone_image_path'),
]
operations = [
migrations.AlterField(
model_name='zone',
name='image_path',
field=models.CharField(blank=True, default='test_image.png', help_text="Например 'test_image_2.png'", max_length=255, verbose_name='Путь к изображению'),
),
migrations.AlterField(
model_name='zone',
name='model_path',
field=models.CharField(blank=True, help_text="Например '/static-models/AerBIM-Monitor_ASM-HTViewer_Expo2017Astana_20250908_L_+76190.glb'", max_length=255, null=True, verbose_name='Путь к 3D модели'),
),
]

View File

@@ -175,6 +175,9 @@ class Zone(models.Model):
object = models.ForeignKey(Object, on_delete=models.CASCADE, related_name="zones", verbose_name="Объект")
name = models.CharField(max_length=255, verbose_name="Название")
floor = models.PositiveSmallIntegerField(verbose_name="Этаж")
image_path = models.CharField(max_length=255, verbose_name="Путь к изображению", blank=True, default="test_image.png", help_text="Например 'test_image_2.png'")
model_path = models.CharField(max_length=255, verbose_name="Путь к 3D модели", blank=True, null=True, help_text="Например '/static-models/AerBIM-Monitor_ASM-HTViewer_Expo2017Astana_20250908_L_+76190.glb'")
order = models.PositiveSmallIntegerField(default=0, verbose_name="Порядок")
sensors = models.ManyToManyField(Sensor, related_name="zones", verbose_name="Датчики")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -182,7 +185,7 @@ class Zone(models.Model):
class Meta:
verbose_name = "Зона"
verbose_name_plural = "Зоны"
ordering = ["object", "floor", "name"] # сортировка сначала по объекту, потом по этажу
ordering = ["object", "floor", "order", "name"] # сортировка по объекту, этажу, порядку, названию
def clean(self):
from django.core.exceptions import ValidationError