From c03e29537a4787294f64bf6639b1a74fd30442bf Mon Sep 17 00:00:00 2001 From: cisar2218 Date: Tue, 24 Feb 2026 09:10:43 +0100 Subject: [PATCH 01/36] record select UI --- backend/DOSPORTAL/admin.py | 2 + .../migrations/0006_measurementsegment.py | 29 + .../0007_remove_measurement_records.py | 17 + backend/DOSPORTAL/models/__init__.py | 4 +- backend/DOSPORTAL/models/measurements.py | 49 +- .../tests/api/test_spectral_record.py | 2 +- backend/api/serializers/measurements.py | 28 +- backend/api/urls.py | 2 + backend/api/views/__init__.py | 4 + backend/api/views/measurements.py | 32 +- backend/api/views/spectrals.py | 12 +- frontend/src/App.tsx | 4 + frontend/src/components/WidePageLayout.tsx | 32 + frontend/src/pages/MeasurementCreatePage.tsx | 363 +++++++++ frontend/src/pages/MeasurementsPage.tsx | 2 +- frontend/src/pages/RecordSelectorPage.tsx | 688 ++++++++++++++++++ frontend/src/theme.ts | 1 + 17 files changed, 1253 insertions(+), 18 deletions(-) create mode 100644 backend/DOSPORTAL/migrations/0006_measurementsegment.py create mode 100644 backend/DOSPORTAL/migrations/0007_remove_measurement_records.py create mode 100644 frontend/src/components/WidePageLayout.tsx create mode 100644 frontend/src/pages/MeasurementCreatePage.tsx create mode 100644 frontend/src/pages/RecordSelectorPage.tsx diff --git a/backend/DOSPORTAL/admin.py b/backend/DOSPORTAL/admin.py index 9e08b5e..a09bfb6 100644 --- a/backend/DOSPORTAL/admin.py +++ b/backend/DOSPORTAL/admin.py @@ -14,6 +14,7 @@ Flight, MeasurementDataFlight, MeasurementCampaign, + MeasurementSegment, Trajectory, TrajectoryPoint, SpectrumData, @@ -95,6 +96,7 @@ class DetectorAdmin(admin.ModelAdmin): admin.site.register(Airports, AirportsAdmin) admin.site.register(Flight) admin.site.register(MeasurementDataFlight) +admin.site.register(MeasurementSegment) admin.site.register(MeasurementCampaign) diff --git a/backend/DOSPORTAL/migrations/0006_measurementsegment.py b/backend/DOSPORTAL/migrations/0006_measurementsegment.py new file mode 100644 index 0000000..1adb207 --- /dev/null +++ b/backend/DOSPORTAL/migrations/0006_measurementsegment.py @@ -0,0 +1,29 @@ +# Generated by Django 6.0.2 on 2026-02-20 14:30 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('DOSPORTAL', '0005_alter_spectralrecord_owner'), + ] + + operations = [ + migrations.CreateModel( + name='MeasurementSegment', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('time_from', models.DateTimeField(blank=True, null=True, verbose_name='Segment start time')), + ('time_to', models.DateTimeField(blank=True, null=True, verbose_name='Segment end time')), + ('position', models.IntegerField(default=0, verbose_name='Position / order within measurement')), + ('measurement', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='segments', to='DOSPORTAL.measurement', verbose_name='Measurement')), + ('spectral_record', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='segments', to='DOSPORTAL.spectralrecord', verbose_name='Spectral record')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/backend/DOSPORTAL/migrations/0007_remove_measurement_records.py b/backend/DOSPORTAL/migrations/0007_remove_measurement_records.py new file mode 100644 index 0000000..5b71666 --- /dev/null +++ b/backend/DOSPORTAL/migrations/0007_remove_measurement_records.py @@ -0,0 +1,17 @@ +# Generated by Django 6.0.2 on 2026-02-20 14:49 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('DOSPORTAL', '0006_measurementsegment'), + ] + + operations = [ + migrations.RemoveField( + model_name='measurement', + name='records', + ), + ] diff --git a/backend/DOSPORTAL/models/__init__.py b/backend/DOSPORTAL/models/__init__.py index d5cac88..35db8f2 100644 --- a/backend/DOSPORTAL/models/__init__.py +++ b/backend/DOSPORTAL/models/__init__.py @@ -5,7 +5,7 @@ from .measurements import ( _validate_data_file, _validate_metadata_file, _validate_log_file, MeasurementDataFlight, - MeasurementCampaign, Measurement, Trajectory, TrajectoryPoint, SpectrumData + MeasurementCampaign, Measurement, MeasurementSegment, Trajectory, TrajectoryPoint, SpectrumData ) from .files import File from .spectrals import SpectralRecord, SpectralRecordArtifact @@ -16,6 +16,6 @@ "Organization", "OrganizationUser", "OrganizationInvite", "_validate_data_file", "_validate_metadata_file", "_validate_log_file", "CARImodel", "Airports", "Flight", "MeasurementDataFlight", - "MeasurementCampaign", "Measurement", "File", "Trajectory", "TrajectoryPoint", "SpectrumData", + "MeasurementCampaign", "Measurement", "MeasurementSegment", "File", "Trajectory", "TrajectoryPoint", "SpectrumData", "SpectralRecord", "SpectralRecordArtifact" ] \ No newline at end of file diff --git a/backend/DOSPORTAL/models/measurements.py b/backend/DOSPORTAL/models/measurements.py index c49c139..a42723d 100644 --- a/backend/DOSPORTAL/models/measurements.py +++ b/backend/DOSPORTAL/models/measurements.py @@ -147,14 +147,6 @@ def __str__(self): def user_directory_path(instance, filename): return "data/user_records/{0}/{1}".format(instance.user.id, filename) - records = models.ManyToManyField( - SpectralRecord, - related_name='measurements', - blank=True, - verbose_name=_('Spectral records'), - help_text=_('Spectral records associated with this measurement.'), - ) - files = models.ManyToManyField( File, related_name='measurements', @@ -181,7 +173,46 @@ def user_directory_path(instance, filename): help_text=_('Measurement campaigns this measurement belongs to.'), ) - + +class MeasurementSegment(UUIDMixin): + """ + A segment represents a (part of a) SpectralRecord within a Measurement. + A measurement can consist of multiple records or record parts sliced by time. + """ + + measurement = models.ForeignKey( + Measurement, + on_delete=models.CASCADE, + related_name='segments', + verbose_name=_('Measurement'), + ) + + spectral_record = models.ForeignKey( + SpectralRecord, + on_delete=models.CASCADE, + related_name='segments', + verbose_name=_('Spectral record'), + ) + + time_from = models.DateTimeField( + verbose_name=_('Segment start time'), + null=True, + blank=True, + ) + + time_to = models.DateTimeField( + verbose_name=_('Segment end time'), + null=True, + blank=True, + ) + + position = models.IntegerField( + verbose_name=_('Position / order within measurement'), + default=0, + ) + + def __str__(self): + return f"Segment {self.position} of {self.measurement} — {self.spectral_record}" class Trajectory(UUIDMixin): diff --git a/backend/DOSPORTAL/tests/api/test_spectral_record.py b/backend/DOSPORTAL/tests/api/test_spectral_record.py index ede799d..9f07777 100644 --- a/backend/DOSPORTAL/tests/api/test_spectral_record.py +++ b/backend/DOSPORTAL/tests/api/test_spectral_record.py @@ -228,7 +228,7 @@ def test_list_multiple_records(self, api_client, user_with_org, log_file, organi statuses = [item['processing_status'] for item in response.data] assert SpectralRecord.PROCESSING_PENDING in statuses assert SpectralRecord.PROCESSING_COMPLETED in statuses - + def test_owner_field_returns_spectral_record_owner_name(self, api_client, user_with_org, organization, sample_candy_log): from django.utils import timezone diff --git a/backend/api/serializers/measurements.py b/backend/api/serializers/measurements.py index b60b3ba..281fc23 100644 --- a/backend/api/serializers/measurements.py +++ b/backend/api/serializers/measurements.py @@ -1,7 +1,7 @@ """Measurements relatedserializers.""" from rest_framework import serializers -from DOSPORTAL.models import Measurement, File, SpectrumData +from DOSPORTAL.models import Measurement, File, SpectrumData, MeasurementSegment from DOSPORTAL.models.spectrals import SpectralRecord, SpectralRecordArtifact from DOSPORTAL.models.flights import Flight, Airports from .organizations import OrganizationSummarySerializer, UserSummarySerializer @@ -92,3 +92,29 @@ def validate_raw_file(self, value): if value and value.file_type != File.FILE_TYPE_LOG: raise serializers.ValidationError("Raw file must be of type 'log'") return value + + +class MeasurementSegmentSerializer(serializers.ModelSerializer): + class Meta: + model = MeasurementSegment + fields = ('id', 'measurement', 'spectral_record', 'time_from', 'time_to', 'position') + read_only_fields = ('id',) + + +class MeasurementCreateSerializer(serializers.ModelSerializer): + owner_id = serializers.UUIDField(required=False, allow_null=True, write_only=True) + + class Meta: + model = Measurement + fields = ( + 'name', 'measurement_type', 'description', 'public', + 'time_start', 'time_end', + 'base_location_lat', 'base_location_lon', 'base_location_alt', + 'owner_id', + ) + + def create(self, validated_data): + from DOSPORTAL.models.organizations import Organization + owner_id = validated_data.pop('owner_id', None) + owner = Organization.objects.filter(id=owner_id).first() if owner_id else None + return Measurement.objects.create(owner=owner, **validated_data) diff --git a/backend/api/urls.py b/backend/api/urls.py index a9416c4..b80fcf6 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -16,7 +16,9 @@ # measurements path("measurement/", views.MeasurementsGet), path("measurement/add/", views.MeasurementsPost), + path("measurement/create/", views.MeasurementCreate), path("measurement//", views.MeasurementDetail), + path("measurement-segment/", views.MeasurementSegmentCreate), # File endpoints path("file/", views.FileList), path("file//", views.FileDetail), diff --git a/backend/api/views/__init__.py b/backend/api/views/__init__.py index efa61eb..ecffcfc 100644 --- a/backend/api/views/__init__.py +++ b/backend/api/views/__init__.py @@ -33,6 +33,8 @@ MeasurementsGet, MeasurementsPost, MeasurementDetail, + MeasurementCreate, + MeasurementSegmentCreate, ) # File views @@ -82,6 +84,8 @@ "MeasurementsGet", "MeasurementsPost", "MeasurementDetail", + "MeasurementCreate", + "MeasurementSegmentCreate", # Files "FileList", "FileDetail", diff --git a/backend/api/views/measurements.py b/backend/api/views/measurements.py index cb0b446..b3860ed 100644 --- a/backend/api/views/measurements.py +++ b/backend/api/views/measurements.py @@ -6,8 +6,9 @@ from drf_spectacular.utils import extend_schema, OpenApiParameter from drf_spectacular.types import OpenApiTypes -from DOSPORTAL.models import Measurement +from DOSPORTAL.models import Measurement, MeasurementSegment from ..serializers import MeasurementsSerializer +from ..serializers.measurements import MeasurementCreateSerializer, MeasurementSegmentSerializer from .organizations import get_user_organizations, check_org_member_permission @@ -84,4 +85,31 @@ def MeasurementDetail(request, measurement_id): return Response( {'error': 'You do not have permission to access this measurement'}, status=status.HTTP_403_FORBIDDEN - ) \ No newline at end of file + ) + + +@extend_schema(tags=["Measurements"]) +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +def MeasurementCreate(request): + serializer = MeasurementCreateSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + measurement = serializer.save(author=request.user) + return Response(MeasurementsSerializer(measurement).data, status=status.HTTP_201_CREATED) + + +@extend_schema(tags=["Measurements"]) +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +def MeasurementSegmentCreate(request): + serializer = MeasurementSegmentSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + measurement = serializer.validated_data['measurement'] + if measurement.author != request.user: + has_perm, _ = check_org_member_permission(request.user, measurement.owner) if measurement.owner else (False, None) + if not has_perm: + return Response({'error': 'Permission denied'}, status=status.HTTP_403_FORBIDDEN) + segment = serializer.save() + return Response(MeasurementSegmentSerializer(segment).data, status=status.HTTP_201_CREATED) \ No newline at end of file diff --git a/backend/api/views/spectrals.py b/backend/api/views/spectrals.py index 7547153..09b94aa 100644 --- a/backend/api/views/spectrals.py +++ b/backend/api/views/spectrals.py @@ -33,7 +33,13 @@ def SpectralRecordList(request): ) | SpectralRecord.objects.filter(author=request.user) queryset = queryset.select_related('raw_file', 'author', 'owner').distinct() - + + processing_status = request.GET.get('processing_status') + if processing_status: + queryset = queryset.filter(processing_status=processing_status.lower()) + + print(f"processing_status {processing_status}") + data = [] for record in queryset: data.append({ @@ -44,7 +50,9 @@ def SpectralRecordList(request): 'author': record.author.username if record.author else None, 'owner': record.owner.name if record.owner else None, 'raw_file_id': str(record.raw_file.id) if record.raw_file else None, - 'artifacts_count': record.artifacts.count() + 'artifacts_count': record.artifacts.count(), + 'time_start': record.time_start.isoformat() if record.time_start else None, + 'record_duration': record.record_duration.total_seconds() if record.record_duration else None, }) return Response(data) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a10e558..647780b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,6 +22,8 @@ import { SpectralRecordStatusPage } from './pages/SpectralRecordStatusPage'; import { SpectralRecordDetailPage } from './pages/SpectralRecordDetailPage'; import { AirportDetailPage } from './pages/AirportDetailPage'; import { UserDetailPage } from './pages/UserDetailPage'; +import { RecordSelectorPage } from './pages/RecordSelectorPage'; +import { MeasurementCreatePage } from './pages/MeasurementCreatePage'; function App() { const { API_BASE, ORIGIN_BASE, isAuthed, login, signup, logout, getAuthHeader } = useAuth() @@ -52,6 +54,8 @@ function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/WidePageLayout.tsx b/frontend/src/components/WidePageLayout.tsx new file mode 100644 index 0000000..5c558ff --- /dev/null +++ b/frontend/src/components/WidePageLayout.tsx @@ -0,0 +1,32 @@ +interface PageLayoutProps { + children: React.ReactNode + backgroundImage?: string +} + +export const WidePageLayout = ({ children, backgroundImage }: PageLayoutProps) => { + return ( +
+
+ {children} +
+
+ ) +} diff --git a/frontend/src/pages/MeasurementCreatePage.tsx b/frontend/src/pages/MeasurementCreatePage.tsx new file mode 100644 index 0000000..f7f90a0 --- /dev/null +++ b/frontend/src/pages/MeasurementCreatePage.tsx @@ -0,0 +1,363 @@ +import { useState, useEffect } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' +import { PageLayout } from '../components/PageLayout' +import { Section } from '../components/Section' +import { SortableTable } from '../components/SortableTable' +import type { TableColumn } from '../components/SortableTable' +import { LabeledInput } from '../components/LabeledInput' +import { theme } from '../theme' + +type SpectralRecord = { + id: string + name: string + created: string + author: string | null + owner: string | null + raw_file_id: string | null + artifacts_count: number + time_start: string | null + record_duration: number | null +} + +type OrgOption = { value: string; label: string } + +const MEASUREMENT_TYPES = [ + { value: 'D', label: 'Debug' }, + { value: 'S', label: 'Static' }, + { value: 'M', label: 'Mobile (ground)' }, + { value: 'C', label: 'Civil airborne' }, + { value: 'A', label: 'Special airborne' }, +] + +const formatDate = (dateStr?: string | null) => { + if (!dateStr) return '—' + try { + return new Date(dateStr).toLocaleString('en-US', { + year: 'numeric', month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit', + }) + } catch { return dateStr } +} + +const inputStyle: React.CSSProperties = { + width: '100%', + padding: theme.spacing.sm, + border: `1px solid ${theme.colors.border}`, + borderRadius: theme.borders.radius.sm, + fontSize: theme.typography.fontSize.sm, + color: theme.colors.textDark, + backgroundColor: theme.colors.bg, + boxSizing: 'border-box', +} + +const labelStyle: React.CSSProperties = { + display: 'block', + marginBottom: theme.spacing.xs, + fontWeight: theme.typography.fontWeight.medium, + color: theme.colors.textDark, + fontSize: theme.typography.fontSize.sm, +} + +const fieldWrapper: React.CSSProperties = { + marginBottom: theme.spacing['2xl'], +} + +const buttonBase: React.CSSProperties = { + padding: `${theme.spacing.sm} ${theme.spacing.xl}`, + borderRadius: theme.borders.radius.sm, + border: '1px solid transparent', + fontWeight: theme.typography.fontWeight.medium, + fontSize: theme.typography.fontSize.sm, + cursor: 'pointer', +} + +export const MeasurementCreatePage = ({ + apiBase, + isAuthed, + getAuthHeader, +}: { + apiBase: string + isAuthed: boolean + getAuthHeader: () => { Authorization?: string } +}) => { + const navigate = useNavigate() + const location = useLocation() + const selectedRecords: SpectralRecord[] = location.state?.selectedRecords ?? [] + + const [name, setName] = useState('') + const [measurementType, setMeasurementType] = useState('S') + const [description, setDescription] = useState('') + const [isPublic, setIsPublic] = useState(true) + const [ownerId, setOwnerId] = useState('') + const [timeStart, setTimeStart] = useState('') + const [timeEnd, setTimeEnd] = useState('') + const [orgs, setOrgs] = useState([]) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (!isAuthed) return + fetch(`${apiBase}/user/organizations/owned/`, { + headers: { 'Content-Type': 'application/json', ...getAuthHeader() }, + }) + .then((r) => r.json()) + .then((data: { id: string; name: string }[]) => + setOrgs(data.map((o) => ({ value: o.id, label: o.name }))) + ) + .catch(() => setOrgs([])) + }, [apiBase, isAuthed, getAuthHeader]) + + const columns: TableColumn[] = [ + { + id: 'name', key: 'name', label: 'Name', + render: (v) => ( + + {String(v)} + + ), + }, + { + id: 'owner', key: 'owner', label: 'Owner', + render: (v) => v ? String(v) : , + }, + { + id: 'time_start', key: 'time_start', label: 'Start time', + render: (v) => formatDate(v as string | null), + }, + { + id: 'created', key: 'created', label: 'Uploaded', + render: (v) => formatDate(v as string), + }, + ] + + const handleSubmit = async () => { + if (!name.trim()) { setError('Measurement name is required.'); return } + setError(null) + setSubmitting(true) + try { + const measRes = await fetch(`${apiBase}/measurement/create/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...getAuthHeader() }, + body: JSON.stringify({ + name: name.trim(), + measurement_type: measurementType, + description, + public: isPublic, + owner_id: ownerId || null, + time_start: timeStart || null, + time_end: timeEnd || null, + }), + }) + if (!measRes.ok) { + const err = await measRes.json().catch(() => ({})) + throw new Error(err?.name?.[0] ?? err?.error ?? `HTTP ${measRes.status}`) + } + const measurement = await measRes.json() + + await Promise.all( + selectedRecords.map((record, index) => { + const timeFrom = record.time_start ?? null + const timeTo = + record.time_start && record.record_duration + ? new Date( + new Date(record.time_start).getTime() + record.record_duration * 1000 + ).toISOString() + : null + return fetch(`${apiBase}/measurement-segment/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...getAuthHeader() }, + body: JSON.stringify({ + measurement: measurement.id, + spectral_record: record.id, + time_from: timeFrom, + time_to: timeTo, + position: index, + }), + }) + }) + ) + + navigate(`/measurement/${measurement.id}`) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Unknown error') + } finally { + setSubmitting(false) + } + } + + if (!isAuthed) { + return ( + +
+
Login required.
+
+
+ ) + } + + return ( + + {/* Header bar */} +
+

+ Create Measurement +

+
+ + +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ {/* ---- Left: form ---- */} +
+ setName(e.target.value)} + placeholder="Measurement name" + required + /> + +
+ + +
+ +
+ + +
+ +
+ setIsPublic(e.target.checked)} + style={{ width: '1rem', height: '1rem', cursor: 'pointer', accentColor: theme.colors.primary }} + /> + +
+ +
+ + setTimeStart(e.target.value)} style={inputStyle} /> +
+ +
+ + setTimeEnd(e.target.value)} style={inputStyle} /> +
+ +
+ +