diff --git a/mcserver/migrations/0046_session_save_local.py b/mcserver/migrations/0046_session_save_local.py new file mode 100644 index 0000000..1af96f0 --- /dev/null +++ b/mcserver/migrations/0046_session_save_local.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.14 on 2026-06-25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mcserver', '0045_video_islidar'), + ] + + operations = [ + migrations.AddField( + model_name='session', + name='save_local', + field=models.BooleanField(default=False), + ), + ] diff --git a/mcserver/migrations/0047_video_saved_local.py b/mcserver/migrations/0047_video_saved_local.py new file mode 100644 index 0000000..8929488 --- /dev/null +++ b/mcserver/migrations/0047_video_saved_local.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.14 on 2026-06-25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mcserver', '0046_session_save_local'), + ] + + operations = [ + migrations.AddField( + model_name='video', + name='saved_local', + field=models.BooleanField(default=False), + ), + ] diff --git a/mcserver/models.py b/mcserver/models.py index 101af3c..bd8a1ed 100644 --- a/mcserver/models.py +++ b/mcserver/models.py @@ -80,6 +80,7 @@ class Session(models.Model): trashed_at = models.DateTimeField(blank=True, null=True) isMono = models.BooleanField(default=False, db_index=True) + save_local = models.BooleanField(default=False) class Meta: ordering = ['-created_at'] @@ -179,6 +180,7 @@ class Video(models.Model): keypoints = models.FileField(blank=True, null=True) parameters = models.JSONField(blank=True, null=True) isLidar = models.BooleanField(default=False) + saved_local = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True, db_index=True) updated_at = models.DateTimeField(auto_now=True, db_index=True) diff --git a/mcserver/serializers.py b/mcserver/serializers.py index 7d54c66..e67fc0d 100644 --- a/mcserver/serializers.py +++ b/mcserver/serializers.py @@ -145,7 +145,7 @@ class VideoSerializer(serializers.ModelSerializer): required=False) class Meta: model = Video - fields = ['id', 'trial', 'device_id', 'video', 'video_url', 'video_thumb', 'parameters', 'isLidar', 'created_at', 'updated_at'] + fields = ['id', 'trial', 'device_id', 'video', 'video_url', 'video_thumb', 'parameters', 'isLidar', 'saved_local', 'created_at', 'updated_at'] # Serializers define the API representation. @@ -237,7 +237,7 @@ class Meta: fields = [ 'id', 'user', 'public', 'name', 'sessionName', 'qrcode', 'meta', 'trials', 'server', - 'subject', 'isMono', + 'subject', 'isMono', 'save_local', 'created_at', 'updated_at', 'trashed', 'trashed_at', 'trials_count', 'trashed_trials_count', ] @@ -255,7 +255,7 @@ class Meta: fields = [ 'id', 'user', 'public', 'name', 'sessionName', 'qrcode', 'meta', 'trials', 'server', - 'subject', 'isMono', + 'subject', 'isMono', 'save_local', 'created_at', 'updated_at', 'trashed', 'trashed_at', 'trials_count', 'trashed_trials_count', ] diff --git a/mcserver/views.py b/mcserver/views.py index 183bec2..29a577a 100644 --- a/mcserver/views.py +++ b/mcserver/views.py @@ -253,6 +253,52 @@ def get_queryset(self): def api_health_check(self, request): return Response({"status": "True"}) + @action(detail=True, methods=['get', 'patch'], permission_classes=[AllowAny]) + def save_local(self, request, pk): + try: + if pk == 'undefined': + raise ValueError(_("undefined_uuid")) + + session = get_object_or_404(Session, pk=pk) + + if request.method == 'GET': + return Response({"save_local": session.save_local}) + + if not request.user.is_authenticated: + raise NotAuthenticated(_('login_needed')) + + has_permission = ( + IsOwner().has_permission(request, self) and + IsOwner().has_object_permission(request, self, session) + ) or IsAdmin().has_object_permission(request, self, session) or \ + IsBackend().has_object_permission(request, self, session) + + if not has_permission: + raise PermissionDenied(_('permission_denied')) + + save_local = request.data.get('save_local', request.query_params.get('save_local')) + if isinstance(save_local, bool): + session.save_local = save_local + elif isinstance(save_local, str) and save_local.lower() in ['true', 'false']: + session.save_local = save_local.lower() == 'true' + else: + return Response( + {'save_local': ['Expected true or false.']}, + status=status.HTTP_400_BAD_REQUEST + ) + + session.save(update_fields=['save_local', 'updated_at']) + return Response({"save_local": session.save_local}) + + except Http404: + if settings.DEBUG: + raise APIException(_("error") % {"error_message": str(traceback.format_exc())}) + raise NotFound(_("session_uuid_not_found") % {"uuid": str(pk)}) + except ValueError: + if settings.DEBUG: + raise APIException(_("error") % {"error_message": str(traceback.format_exc())}) + raise NotFound(_("session_uuid_not_valid") % {"uuid": str(pk)}) + @action( detail=True, methods=["get", "post"], @@ -711,6 +757,7 @@ def new_subject(self, request, pk): if not user.is_authenticated: user = User.objects.get(id=1) sessionNew.user = user + sessionNew.save_local = sessionOld.save_local except Http404: if settings.DEBUG: @@ -788,7 +835,16 @@ def get_status(self, request, pk): # if not all videos uploaded then the status is 'uploading' # if results are not ready then processing # otherwise it's ready again - if any([(not v.video) for v in trial.video_set.all()]): + if session.save_local: + is_waiting_for_videos = any([ + (not v.video and not v.saved_local) for v in trial.video_set.all() + ]) + else: + is_waiting_for_videos = any([ + (not v.video) for v in trial.video_set.all() + ]) + + if is_waiting_for_videos: status = 'uploading' elif trial.result_set.count() == 0: status = 'processing' @@ -808,7 +864,11 @@ def get_status(self, request, pk): n_videos_uploaded = 0 n_cameras_connected = Video.objects.filter(trial=trial).count() for video in Video.objects.filter(trial=trial).all(): - if video.video and video.video.url: + if session.save_local: + video_uploaded = video.video or video.saved_local + else: + video_uploaded = video.video and video.video.url + if video_uploaded: n_videos_uploaded = n_videos_uploaded + 1 video_url = None @@ -835,6 +895,7 @@ def get_status(self, request, pk): res = { "status": status, "trial": trial_url, + "trialname": trial.name if trial else None, "video": video_url, "framerate": frameRate, "newSessionURL": newSessionURL, @@ -1884,6 +1945,9 @@ def perform_update(self, serializer): if "isLidar" in self.request.data: serializer.validated_data["isLidar"] = str(self.request.data.get("isLidar", "")).lower() == "true" + if "saved_local" in self.request.data: + serializer.validated_data["saved_local"] = str(self.request.data.get("saved_local", "")).lower() == "true" + super().perform_update(serializer) class ResultViewSet(viewsets.ModelViewSet): @@ -2738,4 +2802,4 @@ def data(self, request, pk): return Response(dashboard.get_available_data()) return Response(dashboard.get_available_data( - only_public=True, subject_id=request.GET.get('subject_id'), share_token=request.GET.get('share_token'))) \ No newline at end of file + only_public=True, subject_id=request.GET.get('subject_id'), share_token=request.GET.get('share_token'))) diff --git a/tests/test_views.py b/tests/test_views.py index 7d72ce3..7326b40 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,11 +1,13 @@ from unittest import mock +import uuid from django.test import TestCase from rest_framework.permissions import IsAuthenticated from rest_framework.reverse import reverse from mcserver.models import ( - User, AnalysisFunction, AnalysisResult, AnalysisResultState + User, AnalysisFunction, AnalysisResult, AnalysisResultState, + Session, Trial, Video ) from mcserver.serializers import ( AnalysisFunctionSerializer, AnalysisResultSerializer @@ -147,3 +149,62 @@ def test_analysis_result_on_ready_responses_200_if_result_ready(self): ) self.assertEqual(response.status_code, 200) self.assertEqual(response.data, AnalysisResultSerializer(result).data) + + def test_session_status_waits_for_video_field_when_not_save_local(self): + session = Session.objects.create(user=self.user, save_local=False) + trial = Trial.objects.create( + session=session, status='stopped', name='walk-fast' + ) + Video.objects.create( + trial=trial, device_id=uuid.uuid4(), video='uploaded-a.mov', + saved_local=True + ) + pending_video = Video.objects.create( + trial=trial, device_id=uuid.uuid4(), saved_local=True + ) + + self.client.force_login(self.user) + response = self.client.get(f'/sessions/{session.pk}/status/') + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['status'], 'uploading') + self.assertEqual(response.data['trialname'], 'walk-fast') + self.assertEqual(response.data['n_videos_uploaded'], 1) + + pending_video.video = 'uploaded-b.mov' + pending_video.save(update_fields=['video']) + response = self.client.get(f'/sessions/{session.pk}/status/') + + self.assertEqual(response.data['status'], 'processing') + self.assertEqual(response.data['n_videos_uploaded'], 2) + + def test_session_status_waits_for_video_or_saved_local_when_save_local(self): + session = Session.objects.create(user=self.user, save_local=True) + trial = Trial.objects.create( + session=session, status='stopped', name='squat-1' + ) + Video.objects.create( + trial=trial, device_id=uuid.uuid4(), video='uploaded-a.mov', + saved_local=False + ) + Video.objects.create( + trial=trial, device_id=uuid.uuid4(), video='', saved_local=True + ) + pending_video = Video.objects.create( + trial=trial, device_id=uuid.uuid4(), video='', saved_local=False + ) + + self.client.force_login(self.user) + response = self.client.get(f'/sessions/{session.pk}/status/') + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['status'], 'uploading') + self.assertEqual(response.data['trialname'], 'squat-1') + self.assertEqual(response.data['n_videos_uploaded'], 2) + + pending_video.saved_local = True + pending_video.save(update_fields=['saved_local']) + response = self.client.get(f'/sessions/{session.pk}/status/') + + self.assertEqual(response.data['status'], 'processing') + self.assertEqual(response.data['n_videos_uploaded'], 3)