from django.test import TestCase, Client from django.contrib.auth.models import User from django.urls import reverse from django.conf import settings from datetime import datetime, timezone, timedelta import json import xml.etree.ElementTree as ET import tempfile import os from common.models import UserConfig, AppView from .models import PanoramaxUpload from .utils import parse_gpx, interpolate_gpx, haversine_distance, calculate_stable_heading class PanoramaxAppViewTestCase(TestCase): """Test cases for the Panoramax app view access and configurations""" def setUp(self): # Create a test user self.user = User.objects.create_user( username='testuser', email='test@example.com', password='testpass123' ) # Create user config self.user_config = UserConfig.objects.create(user=self.user) # Create Panoramax AppView self.panoramax_view = AppView.objects.create( code='panoramax', name_fr='Photos 360', name_nl="360° Foto's", icon='bi-images', url_name='panoramax:index', is_default=False, order=40 ) def test_appview_created(self): """Test that Panoramax AppView fields match expectations""" self.assertEqual(self.panoramax_view.code, 'panoramax') self.assertEqual(self.panoramax_view.name_fr, 'Photos 360') self.assertEqual(self.panoramax_view.url_name, 'panoramax:index') def test_user_without_permission(self): """Test that user without permission cannot access panoramax view""" client = Client() client.login(username='testuser', password='testpass123') response = client.get(reverse('panoramax:index')) self.assertEqual(response.status_code, 403) def test_user_with_permission(self): """Test that user with permission can access panoramax view""" self.user_config.accessible_views.add(self.panoramax_view) client = Client() client.login(username='testuser', password='testpass123') response = client.get(reverse('panoramax:index')) self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, 'panoramax/viewer.html') class PanoramaxUploadTestCase(TestCase): """Test cases for Panoramax upload functionality, permissions, and status API""" def setUp(self): self.user = User.objects.create_user( username='uploader', email='uploader@example.com', password='testpass123' ) self.user_config = UserConfig.objects.create(user=self.user) self.panoramax_view = AppView.objects.create( code='panoramax', name_fr='Photos 360', name_nl="360° Foto's", icon='bi-images', url_name='panoramax:index', is_default=False, order=40 ) self.user_config.accessible_views.add(self.panoramax_view) def test_upload_page_access_permission(self): """Test that only users with panoramax_can_upload can access upload page""" client = Client() client.login(username='uploader', password='testpass123') # No permission initially response = client.get(reverse('panoramax:upload')) self.assertEqual(response.status_code, 403) # Grant permission self.user_config.panoramax_can_upload = True self.user_config.save() response = client.get(reverse('panoramax:upload')) self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, 'panoramax/upload.html') def test_upload_status_api(self): """Test the upload status JSON API view""" self.user_config.panoramax_can_upload = True self.user_config.save() upload_task = PanoramaxUpload.objects.create( user=self.user, title='Test Upload Sequence', upload_type='files', status='processing', progress=45, error_message=None ) client = Client() client.login(username='uploader', password='testpass123') response = client.get(reverse('panoramax:upload_status', args=[upload_task.id])) self.assertEqual(response.status_code, 200) data = json.loads(response.content) self.assertEqual(data['status'], 'processing') self.assertEqual(data['progress'], 45) self.assertEqual(data['error_message'], '') class PanoramaxUtilsTestCase(TestCase): """Test cases for helper utilities (GPX parsing, distance, interpolation)""" def test_haversine_distance(self): """Test distance calculation between two points (Bruxelles Grand Place to Gare Centrale)""" # Approx 400m dist = haversine_distance(50.8467, 4.3524, 50.8455, 4.3575) self.assertTrue(350 <= dist <= 450) def test_interpolate_gpx(self): """Test GPX trackpoint time interpolation""" t1 = datetime(2026, 7, 19, 10, 0, 0, tzinfo=timezone.utc) t2 = datetime(2026, 7, 19, 10, 0, 10, tzinfo=timezone.utc) gpx_points = [ {'lat': 50.0, 'lon': 4.0, 'ele': 10.0, 'time': t1}, {'lat': 50.1, 'lon': 4.2, 'ele': 20.0, 'time': t2} ] # Interpolate halfway t_mid = t1 + timedelta(seconds=5) pos = interpolate_gpx(gpx_points, t_mid) self.assertIsNotNone(pos) self.assertAlmostEqual(pos['lat'], 50.05) self.assertAlmostEqual(pos['lon'], 4.1) self.assertAlmostEqual(pos['ele'], 15.0) def test_parse_gpx(self): """Test parsing of GPX XML content""" gpx_content = """ 12.5 13.0 """ with tempfile.NamedTemporaryFile(suffix='.gpx', delete=False, mode='w', encoding='utf-8') as temp_file: temp_file.write(gpx_content) temp_file_path = temp_file.name try: points = parse_gpx(temp_file_path) self.assertEqual(len(points), 2) self.assertEqual(points[0]['lat'], 50.8503) self.assertEqual(points[0]['lon'], 4.3517) self.assertEqual(points[0]['ele'], 12.5) self.assertEqual(points[0]['time'].year, 2026) finally: if os.path.exists(temp_file_path): os.unlink(temp_file_path) def test_parse_gpmf_value(self): """Test GPMF value decoding for float, integers, chars""" from .utils import parse_gpmf_value import struct self.assertEqual(parse_gpmf_value('c', 1, 4, b'test'), 'test') data = struct.pack('>I', 10000000) self.assertEqual(parse_gpmf_value('L', 4, 1, data), 10000000) data_list = struct.pack('>II', 10, 20) self.assertEqual(parse_gpmf_value('L', 4, 2, data_list), (10, 20)) def test_parse_klv(self): """Test GPMF KLV nesting and element parsing""" from .utils import parse_klv import struct gps5_data = struct.pack('>5i', 50850000, 4350000, 100, 15, 15) gps5_klv = b'GPS5l\x04\x00\x05' + gps5_data scal_data = struct.pack('>I', 1000000) scal_klv = b'SCALL\x04\x00\x01' + scal_data strm_payload = gps5_klv + scal_klv strm_klv = b'STRM\x00\x01' + struct.pack('>H', len(strm_payload)) + strm_payload devi_klv = b'DEVI\x00\x01' + struct.pack('>H', len(strm_klv)) + strm_klv parsed = parse_klv(devi_klv) self.assertIn('DEVI', parsed) self.assertEqual(len(parsed['DEVI']), 1) self.assertIn('STRM', parsed['DEVI'][0]) self.assertEqual(len(parsed['DEVI'][0]['STRM']), 1) strm = parsed['DEVI'][0]['STRM'][0] self.assertIn('GPS5', strm) self.assertIn('SCAL', strm) self.assertEqual(strm['SCAL'], 1000000) self.assertEqual(strm['GPS5'], (50850000, 4350000, 100, 15, 15)) from unittest.mock import patch class PanoramaxDeleteViewsTestCase(TestCase): """Test cases for the delete_collection and delete_picture views""" def setUp(self): # Create users self.owner = User.objects.create_user(username='owner', email='owner@example.com', password='password123') self.other = User.objects.create_user(username='other', email='other@example.com', password='password123') self.admin = User.objects.create_user(username='admin', email='admin@example.com', password='password123', is_staff=True) # Access configs self.panoramax_view = AppView.objects.get_or_create( code='panoramax', defaults={ 'name_fr': 'Photos 360', 'name_nl': "360° Foto's", 'icon': 'bi-images', 'url_name': 'panoramax:index', 'is_default': False, 'order': 40 } )[0] for u in [self.owner, self.other, self.admin]: config = UserConfig.objects.get_or_create(user=u)[0] config.accessible_views.add(self.panoramax_view) # Create an upload task belonging to owner with an associated collection self.col_id = "00000000-0000-0000-0000-000000000001" self.upload_task = PanoramaxUpload.objects.create( user=self.owner, title='Owner Sequence', upload_type='files', status='completed', progress=100, associated_collections=[{'id': self.col_id, 'date': '2026-07-21'}] ) def test_delete_collection_requires_post(self): """Test that delete_collection returns 400 Bad Request for GET requests""" client = Client() client.login(username='owner', password='password123') response = client.get(reverse('panoramax:delete_collection', args=[self.col_id])) self.assertEqual(response.status_code, 400) @patch('requests.delete') def test_delete_collection_owner_success(self, mock_delete): """Test that the owner of a collection can successfully delete it""" # Mock requests.delete returning 204 No Content mock_delete.return_value.status_code = 204 client = Client() client.login(username='owner', password='password123') response = client.post(reverse('panoramax:delete_collection', args=[self.col_id])) self.assertEqual(response.status_code, 200) self.assertContains(response, "deleted successfully") # Verify db was cleaned up (collection ID removed from associated_collections) self.upload_task.refresh_from_db() self.assertEqual(self.upload_task.associated_collections, []) def test_delete_collection_non_owner_forbidden(self): """Test that a non-owner non-admin user cannot delete the collection""" client = Client() client.login(username='other', password='password123') response = client.post(reverse('panoramax:delete_collection', args=[self.col_id])) self.assertEqual(response.status_code, 403) @patch('requests.delete') def test_delete_collection_admin_success(self, mock_delete): """Test that an admin (staff) can delete another user's collection""" mock_delete.return_value.status_code = 204 client = Client() client.login(username='admin', password='password123') response = client.post(reverse('panoramax:delete_collection', args=[self.col_id])) self.assertEqual(response.status_code, 200) @patch('requests.delete') def test_delete_picture_owner_success(self, mock_delete): """Test that the owner can delete a picture from their collection""" mock_delete.return_value.status_code = 204 pic_id = "00000000-0000-0000-0000-000000000002" client = Client() client.login(username='owner', password='password123') response = client.post(reverse('panoramax:delete_picture', args=[self.col_id, pic_id])) self.assertEqual(response.status_code, 200) def test_delete_picture_non_owner_forbidden(self): """Test that a non-owner cannot delete a picture""" pic_id = "00000000-0000-0000-0000-000000000002" client = Client() client.login(username='other', password='password123') response = client.post(reverse('panoramax:delete_picture', args=[self.col_id, pic_id])) self.assertEqual(response.status_code, 403) class PanoramaxStableHeadingTestCase(TestCase): """Test cases for the calculate_stable_heading utility""" def test_calculate_stable_heading_forward(self): # Create a simple straight track going north (lon constant, lat increasing) start_time = datetime(2026, 7, 21, 10, 0, 0, tzinfo=timezone.utc) gps_points = [ {'lat': 50.8500, 'lon': 4.3500, 'time': start_time, 'ele': 100}, {'lat': 50.8501, 'lon': 4.3500, 'time': start_time + timedelta(seconds=1), 'ele': 100}, {'lat': 50.8502, 'lon': 4.3500, 'time': start_time + timedelta(seconds=2), 'ele': 100}, {'lat': 50.8503, 'lon': 4.3500, 'time': start_time + timedelta(seconds=3), 'ele': 100}, {'lat': 50.8504, 'lon': 4.3500, 'time': start_time + timedelta(seconds=4), 'ele': 100}, ] pos = gps_points[1] # At lat 50.8501 # Calculate heading - should point straight north (bearing ~0) heading = calculate_stable_heading(pos, gps_points, start_time + timedelta(seconds=1)) # Bearing from (50.8501, 4.3500) to (50.8502, 4.3500) is 0 self.assertAlmostEqual(heading, 0.0, places=2) def test_calculate_stable_heading_backward_at_end(self): # Near end of track, should look backward start_time = datetime(2026, 7, 21, 10, 0, 0, tzinfo=timezone.utc) gps_points = [ {'lat': 50.8500, 'lon': 4.3500, 'time': start_time, 'ele': 100}, {'lat': 50.8501, 'lon': 4.3500, 'time': start_time + timedelta(seconds=1), 'ele': 100}, {'lat': 50.8502, 'lon': 4.3500, 'time': start_time + timedelta(seconds=2), 'ele': 100}, {'lat': 50.8503, 'lon': 4.3500, 'time': start_time + timedelta(seconds=3), 'ele': 100}, {'lat': 50.8504, 'lon': 4.3500, 'time': start_time + timedelta(seconds=4), 'ele': 100}, ] pos = gps_points[4] # At very end of track heading = calculate_stable_heading(pos, gps_points, start_time + timedelta(seconds=4), min_distance=10) # Should look backward to a point at least 10 meters behind, resulting in north travel (~0) self.assertAlmostEqual(heading, 0.0, places=2) def test_calculate_stable_heading_camera_offset(self): # Straight track going north, but with camera offset 90 (looking right) start_time = datetime(2026, 7, 21, 10, 0, 0, tzinfo=timezone.utc) gps_points = [ {'lat': 50.8500, 'lon': 4.3500, 'time': start_time, 'ele': 100}, {'lat': 50.8501, 'lon': 4.3500, 'time': start_time + timedelta(seconds=1), 'ele': 100}, {'lat': 50.8502, 'lon': 4.3500, 'time': start_time + timedelta(seconds=2), 'ele': 100}, ] pos = gps_points[0] heading = calculate_stable_heading(pos, gps_points, start_time, camera_offset=90) # 0 + 90 = 90 self.assertAlmostEqual(heading, 90.0, places=2) # Test negative offset wrap-around heading_neg = calculate_stable_heading(pos, gps_points, start_time, camera_offset=-90) # 0 - 90 = -90 -> 270 self.assertAlmostEqual(heading_neg, 270.0, places=2)