Skip to content

Commit 67baf85

Browse files
committed
DBC22-6169: access webcam database to retrieve is_on property
revert back revert back DBC22-6169: check camera is on status every 5 seconds removed migration files no need debug test 1 debug test 2 debug test 3 debug test 4 debug test 5 debug test 6 debug test 7 debug test 8 debug test 9 debug test 10 fixed backup and restore logic removed comments unused fixed failed test cases fixed failed test case 1 fixed sonarcloud alerts 1 fixed failed test case 2 fixed failed test case 2 added new test cases for processor added more test case to improve coverage improve test coverage 1 improve test coverage 2 improve test coverage 2 improve test coverage 3 improve test coverage 4 improve test coverage 5 improve test coverage 6 improve test coverage 7 removed unused import
1 parent 2907e61 commit 67baf85

11 files changed

Lines changed: 746 additions & 95 deletions

File tree

compose/backend/leader-election.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@
5050

5151
huey_process: subprocess.Popen | None = None
5252

53+
monitor_process = None
54+
5355
# ---------------------------------------------------------------------------
5456
# Lease helpers
5557
# ---------------------------------------------------------------------------
@@ -132,6 +134,17 @@ def _start_huey():
132134
)
133135
logger.info("Huey running (PID %d)", huey_process.pid)
134136

137+
def _start_monitor():
138+
global monitor_process
139+
140+
monitor_process = subprocess.Popen(
141+
["python", "manage.py", "monitor_camera_status"],
142+
stdout=sys.stdout,
143+
stderr=sys.stderr,
144+
cwd="/app/backend",
145+
)
146+
147+
logger.info("Monitor started (PID %d)", monitor_process.pid)
135148

136149
def _stop_huey(timeout: int = 30):
137150
global huey_process
@@ -189,6 +202,7 @@ def main():
189202
# No lease exists yet — race to create it
190203
if _create_lease(api):
191204
leading = True
205+
_start_monitor()
192206
_start_huey()
193207

194208
elif _i_hold_lease(lease):
@@ -202,7 +216,8 @@ def main():
202216
# We hold the lease (e.g. after a container restart)
203217
# but haven't started the process yet.
204218
logger.info("Already hold lease after restart — starting Huey")
205-
leading = True
219+
leading = True
220+
_start_monitor()
206221
_start_huey()
207222
elif not _huey_alive():
208223
# Huey was running, but now it's not.
@@ -225,6 +240,7 @@ def main():
225240
)
226241
logger.info("Takeover successful — we are the new leader")
227242
leading = True
243+
_start_monitor()
228244
_start_huey()
229245
except ApiException as e:
230246
logger.warning("Takeover failed (lost race?): %s", e)

compose/backend/start_tasks.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ set -o errexit
44
set -o pipefail
55
set -o nounset
66

7+
python manage.py monitor_camera_status &
78
python manage.py run_huey

src/backend/apps/consumer/processor.py

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from botocore.config import Config
2828
from django.contrib.gis.geos import Point
2929
from django.db import close_old_connections, connection
30+
import shutil
3031

3132

3233
tf = TimezoneFinder()
@@ -159,7 +160,7 @@ async def consume_queue(queue, name: str):
159160
try:
160161
await process_message(message)
161162
except Exception as e:
162-
logger.error(f"Error processing message from {name}: {e}")
163+
logging.exception(f"Error processing message from {name}: {e}")
163164

164165
async def consume_from(rb_url: str, name: str):
165166
while not stop_event.is_set():
@@ -380,7 +381,7 @@ def watermark(webcam: any, image_data: bytes, tz: str, timestamp: str) -> bytes:
380381
return buffer.read()
381382

382383
except Exception as e:
383-
logger.error(f"Error processing image from camera: {e}")
384+
logging.exception(f"Error processing image from camera: {e}")
384385
return None
385386

386387
def blank_out_image(webcam: any, image_data: bytes, tz: str, timestamp: str) -> bytes:
@@ -426,7 +427,7 @@ def blank_out_image(webcam: any, image_data: bytes, tz: str, timestamp: str) ->
426427
return buffer.read()
427428

428429
except Exception as e:
429-
logger.error(f"Error processing image from camera: {e}")
430+
logging.exception(f"Error processing image from camera: {e}")
430431
return None
431432

432433

@@ -441,7 +442,7 @@ def save_original_image_to_pvc(camera_id: str, image_bytes: bytes):
441442
with open(filepath, "wb") as f:
442443
f.write(image_bytes)
443444
except Exception as e:
444-
logger.error(f"Error saving original image to PVC {filepath}: {e}")
445+
logging.exception(f"Error saving original image to PVC {filepath}: {e}")
445446
logger.info(f"Original image saved to PVC at {filepath}")
446447

447448
def save_watermarked_image_to_pvc(camera_id: str, image_bytes: bytes, timestamp: str, is_on: bool):
@@ -460,23 +461,44 @@ def save_watermarked_image_to_pvc(camera_id: str, image_bytes: bytes, timestamp:
460461
else:
461462
logger.info(f"Blank out image saved to PVC at {filepath}")
462463
except Exception as e:
463-
logger.error(f"Error saving image to PVC {filepath}: {e}")
464+
logging.exception(f"Error saving image to PVC {filepath}: {e}")
464465

465-
def save_watermarked_image_to_drivebc_pvc(camera_id: str, image_bytes: bytes, is_on: bool):
466-
os.makedirs(os.path.dirname(f'{DRIVEBC_PVC_WATERMARKED_PATH}'), exist_ok=True)
466+
def check_backup_exists(camera_id: str) -> bool:
467+
backup_dir = os.path.join(DRIVEBC_PVC_WATERMARKED_PATH, "backup")
468+
backup_filepath = os.path.join(backup_dir, f"{camera_id}.jpg")
469+
return os.path.exists(backup_filepath)
470+
471+
def save_watermarked_image_to_drivebc_pvc(camera_id: str, image_bytes: bytes, is_on: bool):
472+
save_dir = DRIVEBC_PVC_WATERMARKED_PATH
473+
backup_dir = os.path.join(save_dir, "backup")
467474

468-
save_dir = os.path.join(DRIVEBC_PVC_WATERMARKED_PATH)
469475
os.makedirs(save_dir, exist_ok=True)
476+
os.makedirs(backup_dir, exist_ok=True)
477+
470478
filename = f"{camera_id}.jpg"
471479
filepath = os.path.join(save_dir, filename)
480+
backup_filepath = os.path.join(backup_dir, filename)
472481

473482
try:
483+
if os.path.exists(filepath) and not is_on:
484+
if not check_backup_exists(camera_id):
485+
logger.info(
486+
f"Backing up existing image for camera {camera_id} to {backup_filepath}"
487+
)
488+
shutil.move(filepath, backup_filepath)
489+
else:
490+
logger.debug(
491+
f"Backup image already exists for camera {camera_id}. Skipping backup."
492+
)
493+
# Save the new image
474494
with open(filepath, "wb") as f:
475495
f.write(image_bytes)
496+
476497
if is_on:
477-
logger.info(f"Watermarked image saved to drivebc PVC at {filepath}")
498+
logger.info(f"Watermarked image saved to DriveBC PVC at {filepath}")
499+
478500
except Exception as e:
479-
logger.error(f"Error saving image to drivebc PVC {filepath}: {e}")
501+
logging.exception(f"Error saving image to DriveBC PVC {filepath}: {e}")
480502

481503
def delete_watermarked_image_from_pvc(camera_id: str):
482504
save_dir = os.path.join(PVC_WATERMARKED_PATH, camera_id)
@@ -491,7 +513,7 @@ def delete_watermarked_image_from_pvc(camera_id: str):
491513
if os.path.isfile(filepath):
492514
os.remove(filepath)
493515
except Exception as e:
494-
logger.error(f"Error deleting watermarked images from PVC {save_dir}: {e}")
516+
logging.exception(f"Error deleting watermarked images from PVC {save_dir}: {e}")
495517

496518
async def get_images_within(camera_id: str, hours: int = 720) -> list:
497519
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)

src/backend/apps/consumer/tasks.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import logging
22
import io
33

4-
from apps.consumer.processor import process_camera_rows, watermark, blank_out_image, save_watermarked_image_to_pvc, save_watermarked_image_to_drivebc_pvc, delete_watermarked_image_from_pvc, delete_offline_webcam_records
4+
from apps.consumer.processor import check_backup_exists, process_camera_rows, blank_out_image, save_watermarked_image_to_drivebc_pvc, delete_watermarked_image_from_pvc, delete_offline_webcam_records
55
from datetime import datetime
66

77
from apps.webcam.models import Webcam
@@ -38,8 +38,9 @@ def generate_offline_camera_images():
3838
delete_watermarked_image_from_pvc(camera_id)
3939
# Delete all the records from image index table for offline cams
4040
async_to_sync(delete_offline_webcam_records)(camera_id)
41-
# Save blank image for current image displaying
42-
save_watermarked_image_to_drivebc_pvc(camera_id, watermarked, False)
41+
if not check_backup_exists(camera_id):
42+
# Save blank image for current image displaying
43+
save_watermarked_image_to_drivebc_pvc(camera_id, watermarked, False)
4344
# Update postgres db is_on status to False for offline cameras
4445
Webcam.objects.filter(id=camera['id']).update(is_on=False)
4546
else:

src/backend/apps/consumer/tests/test_processor.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
from PIL import Image
23
from unittest.mock import patch, MagicMock, AsyncMock
34
from datetime import datetime
45
import io
@@ -11,6 +12,7 @@
1112
from apps.consumer.processor import (
1213
process_camera_rows,
1314
get_timezone,
15+
process_message,
1416
watermark,
1517
verify_image,
1618
push_to_s3,
@@ -536,6 +538,160 @@ async def run_test():
536538

537539
mock_logger.info.assert_called()
538540

541+
class TestProcessMessage(TestCase):
542+
543+
@patch("apps.consumer.processor.logger")
544+
@patch("apps.consumer.processor.handle_image_message", new_callable=AsyncMock)
545+
@patch("apps.consumer.processor.safe_db_call")
546+
@patch("apps.consumer.processor.generate_local_timestamp")
547+
@patch("apps.consumer.processor.calculate_camera_status")
548+
@patch("apps.consumer.processor.sync_to_async")
549+
async def test_process_message_success(
550+
self,
551+
mock_sync_to_async,
552+
mock_calculate_status,
553+
mock_generate_local_timestamp,
554+
mock_safe_db_call,
555+
mock_handle_image,
556+
mock_logger,
557+
):
558+
message = MagicMock()
559+
message.headers = {
560+
"filename": "12345_20250101.jpg",
561+
"timestamp": "2025-01-01T00:00:00Z",
562+
}
563+
message.body = b"image"
564+
565+
process_cm = AsyncMock()
566+
message.process.return_value = process_cm
567+
568+
def sync_wrapper(func):
569+
if func == mock_calculate_status:
570+
return AsyncMock(return_value=True)
571+
return AsyncMock(return_value="local_timestamp")
572+
573+
mock_sync_to_async.side_effect = sync_wrapper
574+
575+
await process_message(message)
576+
577+
mock_handle_image.assert_awaited_once_with(
578+
"12345",
579+
b"image",
580+
"local_timestamp",
581+
True,
582+
)
583+
584+
mock_logger.info.assert_called_once_with(
585+
"Processed message for camera %s.",
586+
"12345",
587+
)
588+
589+
@patch("apps.consumer.processor.logger")
590+
@patch(
591+
"apps.consumer.processor.handle_image_message",
592+
new_callable=AsyncMock,
593+
side_effect=asyncio.TimeoutError,
594+
)
595+
@patch("apps.consumer.processor.sync_to_async")
596+
async def test_process_message_timeout(
597+
self,
598+
mock_sync_to_async,
599+
mock_handle_image,
600+
mock_logger,
601+
):
602+
message = MagicMock()
603+
message.headers = {
604+
"filename": "123.jpg",
605+
"timestamp": "2025",
606+
}
607+
message.body = b""
608+
609+
message.process.return_value = AsyncMock()
610+
611+
mock_sync_to_async.side_effect = [
612+
AsyncMock(return_value=True),
613+
AsyncMock(return_value="local"),
614+
]
615+
616+
await process_message(message)
617+
618+
mock_logger.error.assert_called_once_with(
619+
"Processing timeout for camera 123"
620+
)
621+
622+
class TestWatermark(TestCase):
623+
624+
def create_image(self, width=640, height=480):
625+
img = Image.new("RGB", (width, height), color="red")
626+
buf = io.BytesIO()
627+
img.save(buf, format="JPEG")
628+
return buf.getvalue()
629+
630+
def test_returns_none_when_image_is_none(self):
631+
webcam = {"dbc_mark": "TEST"}
632+
633+
result = watermark(
634+
webcam,
635+
None,
636+
"America/Vancouver",
637+
"20250720120000000000",
638+
)
639+
640+
self.assertIsNone(result)
641+
642+
def test_successfully_watermarks_image(self):
643+
webcam = {"dbc_mark": "DriveBC"}
644+
645+
result = watermark(
646+
webcam,
647+
self.create_image(),
648+
"America/Vancouver",
649+
"20250720120000000000",
650+
)
651+
652+
self.assertIsNotNone(result)
653+
654+
img = Image.open(io.BytesIO(result))
655+
656+
# original height = 480
657+
self.assertEqual(img.size, (640, 498))
658+
659+
def test_missing_dbc_mark(self):
660+
webcam = {}
661+
662+
result = watermark(
663+
webcam,
664+
self.create_image(),
665+
"America/Vancouver",
666+
"20250720120000000000",
667+
)
668+
669+
self.assertIsNotNone(result)
670+
671+
def test_invalid_timestamp_returns_none(self):
672+
webcam = {"dbc_mark": "DriveBC"}
673+
674+
result = watermark(
675+
webcam,
676+
self.create_image(),
677+
"America/Vancouver",
678+
"bad timestamp",
679+
)
680+
681+
self.assertIsNone(result)
682+
683+
def test_invalid_image_returns_none(self):
684+
webcam = {"dbc_mark": "DriveBC"}
685+
686+
result = watermark(
687+
webcam,
688+
b"not an image",
689+
"America/Vancouver",
690+
"20250720120000000000",
691+
)
692+
693+
self.assertIsNone(result)
694+
539695
def reset_stop_event():
540696
import asyncio
541697
from apps.consumer import processor

0 commit comments

Comments
 (0)