pumaguard 20.post172__py3-none-any.whl → 20.post189__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pumaguard/web_ui.py CHANGED
@@ -4,13 +4,10 @@ Web-UI for Pumaguard.
4
4
 
5
5
  import argparse
6
6
  import hashlib
7
- import io
8
7
  import logging
9
- import os
10
8
  import socket
11
9
  import threading
12
10
  import time
13
- import zipfile
14
11
  from pathlib import (
15
12
  Path,
16
13
  )
@@ -19,20 +16,17 @@ from typing import (
19
16
  TypedDict,
20
17
  )
21
18
 
22
- import yaml
23
19
  from flask import (
24
20
  Flask,
25
21
  jsonify,
26
- request,
27
22
  send_file,
28
23
  send_from_directory,
29
24
  )
30
25
  from flask_cors import (
31
26
  CORS,
32
27
  )
33
- from yaml.representer import (
34
- YAMLError,
35
- )
28
+
29
+ # Note: YAMLError and yaml operations are handled in route modules
36
30
  from zeroconf import (
37
31
  NonUniqueNameException,
38
32
  ServiceInfo,
@@ -42,6 +36,27 @@ from zeroconf import (
42
36
  from pumaguard.presets import (
43
37
  Preset,
44
38
  )
39
+ from pumaguard.web_routes.artifacts import (
40
+ register_artifacts_routes,
41
+ )
42
+ from pumaguard.web_routes.diagnostics import (
43
+ register_diagnostics_routes,
44
+ )
45
+ from pumaguard.web_routes.directories import (
46
+ register_directories_routes,
47
+ )
48
+ from pumaguard.web_routes.folders import (
49
+ register_folders_routes,
50
+ )
51
+ from pumaguard.web_routes.photos import (
52
+ register_photos_routes,
53
+ )
54
+ from pumaguard.web_routes.settings import (
55
+ register_settings_routes,
56
+ )
57
+ from pumaguard.web_routes.sync import (
58
+ register_sync_routes,
59
+ )
45
60
 
46
61
  if TYPE_CHECKING:
47
62
  from pumaguard.server import (
@@ -62,6 +77,18 @@ class PhotoDict(TypedDict):
62
77
  created: float
63
78
 
64
79
 
80
+ class ArtifactDict(TypedDict):
81
+ """Type definition for artifact metadata dictionary."""
82
+
83
+ filename: str
84
+ path: str
85
+ ext: str
86
+ kind: str
87
+ size: int
88
+ modified: float
89
+ created: float
90
+
91
+
65
92
  class WebUI:
66
93
  """
67
94
  The class for the WebUI.
@@ -182,9 +209,7 @@ class WebUI:
182
209
  return sha256_hash.hexdigest()
183
210
 
184
211
  def _setup_routes(self):
185
- """
186
- Set up Flask routes to serve the Flutter web app and API.
187
- """
212
+ """Set up Flask routes for the Flutter web app and API."""
188
213
 
189
214
  @self.app.route("/")
190
215
  def index():
@@ -200,522 +225,23 @@ class WebUI:
200
225
  )
201
226
  return send_file(self.build_dir / "index.html")
202
227
 
203
- @self.app.route("/api/settings", methods=["GET"])
204
- def get_settings():
205
- """
206
- Get current settings.
207
- """
208
- return jsonify(dict(self.presets))
209
-
210
- @self.app.route("/api/settings", methods=["PUT"])
211
- def update_settings():
212
- """Update settings."""
213
- try:
214
- data = request.json
215
- if not data:
216
- return jsonify({"error": "No data provided"}), 400
217
-
218
- allowed_settings = [
219
- "YOLO-min-size",
220
- "YOLO-conf-thresh",
221
- "YOLO-max-dets",
222
- "YOLO-model-filename",
223
- "classifier-model-filename",
224
- "deterrent-sound-file",
225
- "file-stabilization-extra-wait",
226
- "play-sound",
227
- ]
228
-
229
- if len(data) == 0:
230
- raise ValueError("Did not receive any settings")
231
-
232
- for key, value in data.items():
233
- if key in allowed_settings:
234
- logger.debug("Updating %s with %s", key, value)
235
- # Convert hyphenated names to underscored attribute
236
- # names
237
- attr_name = key.replace("-", "_").replace(
238
- "YOLO_", "yolo_"
239
- )
240
- setattr(self.presets, attr_name, value)
241
- else:
242
- logger.debug(
243
- "Skipping unknown/read-only setting: %s", key
244
- )
245
-
246
- # Auto-save settings to disk after updating
247
- try:
248
- filepath = self.presets.settings_file
249
- settings_dict = {}
250
- for key, value in self.presets:
251
- settings_dict[key] = value
252
-
253
- with open(filepath, "w", encoding="utf-8") as f:
254
- yaml.dump(settings_dict, f, default_flow_style=False)
255
-
256
- logger.info("Settings updated and saved to %s", filepath)
257
- except YAMLError:
258
- logger.exception(
259
- "Error saving settings"
260
- ) # logs stack trace too
261
- return (
262
- jsonify(
263
- {
264
- "error": "Settings updated but failed to save due to an internal error" # pylint: disable=line-too-long
265
- }
266
- ),
267
- 500,
268
- )
269
-
270
- return jsonify(
271
- {"success": True, "message": "Settings updated and saved"}
272
- )
273
- except ValueError as e:
274
- logger.error("Error updating settings: %s", e)
275
- return jsonify({"error": str(e)}), 500
276
-
277
- @self.app.route("/api/settings/save", methods=["POST"])
278
- def save_settings():
279
- """Save current settings to a YAML file."""
280
- data = request.json
281
- filepath = data.get("filepath") if data else None
282
-
283
- if not filepath:
284
- # Use default settings file
285
- filepath = self.presets.settings_file
286
-
287
- settings_dict = {}
288
- for key, value in self.presets:
289
- settings_dict[key] = value
290
-
291
- with open(filepath, "w", encoding="utf-8") as f:
292
- yaml.dump(settings_dict, f, default_flow_style=False)
293
-
294
- logger.info("Settings saved to %s", filepath)
295
- return jsonify({"success": True, "filepath": filepath})
296
-
297
- @self.app.route("/api/settings/load", methods=["POST"])
298
- def load_settings():
299
- """Load settings from a YAML file."""
300
- data = request.json
301
- filepath = data.get("filepath") if data else None
302
-
303
- if not filepath:
304
- return jsonify({"error": "No filepath provided"}), 400
305
-
306
- self.presets.load(filepath)
307
- logger.info("Settings loaded from %s", filepath)
308
- return jsonify({"success": True, "message": "Settings loaded"})
309
-
310
- # Photos/Images API
311
- @self.app.route("/api/photos", methods=["GET"])
312
- def get_photos():
313
- """Get list of captured photos."""
314
- photos: list[PhotoDict] = []
315
- for directory in self.image_directories:
316
- if not os.path.exists(directory):
317
- continue
318
-
319
- for filename in os.listdir(directory):
320
- filepath = os.path.join(directory, filename)
321
- if os.path.isfile(filepath):
322
- # Check if it's an image file
323
- ext = os.path.splitext(filename)[1].lower()
324
- if ext in [
325
- ".jpg",
326
- ".jpeg",
327
- ".png",
328
- ".gif",
329
- ".bmp",
330
- ".webp",
331
- ]:
332
- stat = os.stat(filepath)
333
- photos.append(
334
- {
335
- "filename": filename,
336
- "path": filepath,
337
- "directory": directory,
338
- "size": stat.st_size,
339
- "modified": stat.st_mtime,
340
- "created": stat.st_ctime,
341
- }
342
- )
343
-
344
- # Sort by modified time, newest first
345
- photos.sort(key=lambda x: x["modified"], reverse=True)
346
-
347
- return jsonify({"photos": photos, "total": len(photos)})
348
-
349
- @self.app.route("/api/photos/<path:filepath>", methods=["GET"])
350
- def get_photo(filepath):
351
- """Get a specific photo."""
352
- # Security check: ensure the file is in one of the allowed
353
- # directories
354
- abs_filepath = os.path.abspath(filepath)
355
- allowed = False
356
- for directory in self.image_directories:
357
- abs_directory = os.path.abspath(directory)
358
- if abs_filepath.startswith(abs_directory):
359
- allowed = True
360
- break
361
-
362
- if not allowed:
363
- return jsonify({"error": "Access denied"}), 403
364
-
365
- if not os.path.exists(abs_filepath):
366
- return jsonify({"error": "File not found"}), 404
367
-
368
- directory = os.path.dirname(abs_filepath)
369
- filename = os.path.basename(abs_filepath)
370
- return send_from_directory(directory, filename)
371
-
372
- @self.app.route("/api/photos/<path:filepath>", methods=["DELETE"])
373
- def delete_photo(filepath):
374
- """Delete a photo."""
375
- # Security check: ensure the file is in one of the allowed
376
- # directories
377
- abs_filepath = os.path.abspath(filepath)
378
- allowed = False
379
- for directory in self.image_directories:
380
- abs_directory = os.path.abspath(directory)
381
- if abs_filepath.startswith(abs_directory):
382
- allowed = True
383
- break
384
-
385
- if not allowed:
386
- return jsonify({"error": "Access denied"}), 403
387
-
388
- if not os.path.exists(abs_filepath):
389
- return jsonify({"error": "File not found"}), 404
390
-
391
- os.remove(abs_filepath)
392
- logger.info("Deleted photo: %s", abs_filepath)
393
- return jsonify({"success": True, "message": "Photo deleted"})
394
-
395
- # Folder Browser API
396
- @self.app.route("/api/folders", methods=["GET"])
397
- def get_folders():
398
- """Get list of watched folders with image counts."""
399
- folders = []
400
- for directory in self.image_directories:
401
- if not os.path.exists(directory):
402
- continue
403
-
404
- # Count images in folder
405
- image_count = 0
406
- for filename in os.listdir(directory):
407
- filepath = os.path.join(directory, filename)
408
- if os.path.isfile(filepath):
409
- ext = os.path.splitext(filename)[1].lower()
410
- if ext in [
411
- ".jpg",
412
- ".jpeg",
413
- ".png",
414
- ".gif",
415
- ".bmp",
416
- ".webp",
417
- ]:
418
- image_count += 1
419
-
420
- folders.append(
421
- {
422
- "path": directory,
423
- "name": os.path.basename(directory),
424
- "image_count": image_count,
425
- }
426
- )
427
-
428
- return jsonify({"folders": folders})
429
-
430
- @self.app.route(
431
- "/api/folders/<path:folder_path>/images", methods=["GET"]
432
- )
433
- def get_folder_images(folder_path):
434
- """Get list of images in a specific folder."""
435
- # Security check: ensure the folder is in allowed directories
436
- # Normalize and resolve symlinks for both folder_path and allowed
437
- # directories
438
- abs_folder = os.path.realpath(os.path.normpath(folder_path))
439
- allowed = False
440
- for directory in self.image_directories:
441
- abs_directory = os.path.realpath(os.path.normpath(directory))
442
- # Ensure abs_folder is contained in abs_directory
443
- common = os.path.commonpath([abs_folder, abs_directory])
444
- if common == abs_directory:
445
- allowed = True
446
- break
447
-
448
- if not allowed:
449
- return jsonify({"error": "Access denied"}), 403
450
-
451
- if not os.path.exists(abs_folder):
452
- return jsonify({"error": "Folder not found"}), 404
453
-
454
- images = []
455
- for filename in os.listdir(abs_folder):
456
- filepath = os.path.join(abs_folder, filename)
457
- # Security: resolve and ensure file is in allowed folder
458
- resolved_filepath = os.path.realpath(
459
- os.path.normpath(filepath)
460
- )
461
- if (
462
- os.path.commonpath([resolved_filepath, abs_folder])
463
- != abs_folder
464
- ):
465
- continue
466
- if os.path.isfile(resolved_filepath):
467
- ext = os.path.splitext(filename)[1].lower()
468
- if ext in [
469
- ".jpg",
470
- ".jpeg",
471
- ".png",
472
- ".gif",
473
- ".bmp",
474
- ".webp",
475
- ]:
476
- stat = os.stat(resolved_filepath)
477
- images.append(
478
- {
479
- "filename": filename,
480
- "path": resolved_filepath,
481
- "size": stat.st_size,
482
- "modified": stat.st_mtime,
483
- "created": stat.st_ctime,
484
- }
485
- )
486
-
487
- # Sort by modified time, newest first
488
- images.sort(key=lambda x: x["modified"], reverse=True)
489
-
490
- return jsonify({"images": images, "folder": abs_folder})
491
-
492
- @self.app.route("/api/sync/checksums", methods=["POST"])
493
- def calculate_checksums():
494
- """
495
- Calculate checksums for requested files.
496
- Client sends list of files with their checksums,
497
- server returns which files need to be downloaded.
498
- """
499
- data = request.json
500
- if not data or "files" not in data:
501
- return jsonify({"error": "No files provided"}), 400
502
-
503
- client_files = data["files"] # Dict of {filepath: checksum}
504
- files_to_download = []
505
-
506
- for filepath, client_checksum in client_files.items():
507
- # Security check
508
- abs_filepath = os.path.abspath(filepath)
509
- allowed = False
510
- for directory in self.image_directories:
511
- abs_directory = os.path.abspath(directory)
512
- if abs_filepath.startswith(abs_directory):
513
- allowed = True
514
- break
515
-
516
- if not allowed:
517
- continue
518
-
519
- if not os.path.exists(abs_filepath):
520
- continue
521
-
522
- # Calculate server-side checksum
523
- server_checksum = self._calculate_file_checksum(abs_filepath)
524
-
525
- # If checksums don't match or client doesn't have it, mark for
526
- # download
527
- if server_checksum != client_checksum:
528
- stat = os.stat(abs_filepath)
529
- files_to_download.append(
530
- {
531
- "path": filepath,
532
- "checksum": server_checksum,
533
- "size": stat.st_size,
534
- "modified": stat.st_mtime,
535
- }
536
- )
537
-
538
- return jsonify(
539
- {
540
- "files_to_download": files_to_download,
541
- "total": len(files_to_download),
542
- }
543
- )
544
-
545
- @self.app.route("/api/sync/download", methods=["POST"])
546
- def download_files():
547
- """
548
- Download multiple files as a ZIP archive.
549
- """
550
- data = request.json
551
- if not data or "files" not in data:
552
- return jsonify({"error": "No files provided"}), 400
553
-
554
- file_paths = data["files"]
555
-
556
- # Validate all files
557
- validated_files = []
558
- for filepath in file_paths:
559
- abs_filepath = os.path.abspath(filepath)
560
- allowed = False
561
- for directory in self.image_directories:
562
- abs_directory = os.path.abspath(directory)
563
- if abs_filepath.startswith(abs_directory):
564
- allowed = True
565
- break
566
-
567
- if allowed and os.path.exists(abs_filepath):
568
- validated_files.append(abs_filepath)
569
-
570
- if not validated_files:
571
- return jsonify({"error": "No valid files to download"}), 400
572
-
573
- # For single file, return it directly
574
- if len(validated_files) == 1:
575
- directory = os.path.dirname(validated_files[0])
576
- filename = os.path.basename(validated_files[0])
577
- return send_from_directory(
578
- directory, filename, as_attachment=True
579
- )
580
-
581
- memory_file = io.BytesIO()
582
- with zipfile.ZipFile(memory_file, "w", zipfile.ZIP_DEFLATED) as zf:
583
- for filepath in validated_files:
584
- # Use relative path in zip to maintain folder structure
585
- arcname = os.path.basename(filepath)
586
- zf.write(filepath, arcname)
587
-
588
- memory_file.seek(0)
589
- return send_file(
590
- memory_file,
591
- mimetype="application/zip",
592
- as_attachment=True,
593
- download_name="pumaguard_images.zip",
594
- )
595
-
596
- # Directories API
597
- @self.app.route("/api/directories", methods=["GET"])
598
- def get_directories():
599
- """Get list of image directories being monitored."""
600
- return jsonify({"directories": self.image_directories})
601
-
602
- @self.app.route("/api/directories", methods=["POST"])
603
- def add_directory():
604
- """Add a directory to monitor for images."""
605
- data = request.json
606
- directory = data.get("directory") if data else None
607
-
608
- if not directory:
609
- return jsonify({"error": "No directory provided"}), 400
610
-
611
- if not os.path.exists(directory):
612
- return jsonify({"error": "Directory does not exist"}), 400
613
-
614
- if directory not in self.image_directories:
615
- self.image_directories.append(directory)
616
- logger.info("Added image directory: %s", directory)
228
+ # Register modular route groups
229
+ register_settings_routes(self.app, self)
617
230
 
618
- # Register with FolderManager to start watching
619
- if self.folder_manager is not None:
620
- self.folder_manager.register_folder(
621
- directory, self.watch_method
622
- )
623
- logger.info(
624
- "Registered folder with manager: %s (method: %s)",
625
- directory,
626
- self.watch_method,
627
- )
231
+ register_photos_routes(self.app, self)
628
232
 
629
- return jsonify(
630
- {"success": True, "directories": self.image_directories}
631
- )
233
+ register_folders_routes(self.app, self)
632
234
 
633
- @self.app.route("/api/directories/<int:index>", methods=["DELETE"])
634
- def remove_directory(index):
635
- """Remove a directory from watch list."""
636
- if 0 <= index < len(self.image_directories):
637
- removed = self.image_directories.pop(index)
638
- logger.info("Removed image directory: %s", removed)
639
- return jsonify(
640
- {
641
- "success": True,
642
- "directories": self.image_directories,
643
- }
644
- )
645
- return jsonify({"error": "Invalid index"}), 400
646
-
647
- # System/Status API
648
- @self.app.route("/api/status", methods=["GET"])
649
- def get_status():
650
- """Get server status."""
651
- # Add request info to help debug CORS and origin issues
652
- origin = request.headers.get("Origin", "No Origin header")
653
- host = request.headers.get("Host", "No Host header")
654
-
655
- logger.debug(
656
- "API status called - Origin: %s, Host: %s", origin, host
657
- )
235
+ register_sync_routes(self.app, self)
658
236
 
659
- return jsonify(
660
- {
661
- "status": "running",
662
- "version": "1.0.0",
663
- "directories_count": len(self.image_directories),
664
- "host": self.host,
665
- "port": self.port,
666
- "request_origin": origin,
667
- "request_host": host,
668
- }
669
- )
237
+ # Routes delegated to web_routes.artifacts
670
238
 
671
- @self.app.route("/api/diagnostic", methods=["GET"])
672
- def get_diagnostic():
673
- """
674
- Get diagnostic information to help debug URL detection issues.
675
- """
676
- # Get all relevant request information
677
- diagnostic_info = {
678
- "server": {
679
- "host": self.host,
680
- "port": self.port,
681
- "flutter_dir": str(self.flutter_dir),
682
- "build_dir": str(self.build_dir),
683
- "build_exists": self.build_dir.exists(),
684
- "mdns_enabled": self.mdns_enabled,
685
- "mdns_name": self.mdns_name if self.mdns_enabled else None,
686
- "mdns_url": (
687
- f"http://{self.mdns_name}.local:{self.port}"
688
- if self.mdns_enabled
689
- else None
690
- ),
691
- "local_ip": self._get_local_ip(),
692
- },
693
- "request": {
694
- "url": request.url,
695
- "base_url": request.base_url,
696
- "host": request.headers.get("Host", "N/A"),
697
- "origin": request.headers.get("Origin", "N/A"),
698
- "referer": request.headers.get("Referer", "N/A"),
699
- "user_agent": request.headers.get("User-Agent", "N/A"),
700
- },
701
- "expected_behavior": {
702
- "flutter_app_should_detect": f"{request.scheme}://{request.host}", # pylint: disable=line-too-long
703
- "api_calls_should_go_to": f"{request.scheme}://{request.host}/api/...", # pylint: disable=line-too-long
704
- },
705
- "troubleshooting": {
706
- "if_api_calls_go_to_localhost": "Browser is using cached old JavaScript - clear cache", # pylint: disable=line-too-long
707
- "if_page_doesnt_load": "Check that Flutter app is built: make build-ui", # pylint: disable=line-too-long
708
- "if_cors_errors": "Check browser console for details",
709
- },
710
- }
239
+ register_directories_routes(self.app, self)
711
240
 
712
- logger.info(
713
- "Diagnostic endpoint called from: %s", request.remote_addr
714
- )
715
- return jsonify(diagnostic_info)
241
+ register_diagnostics_routes(self.app, self)
716
242
 
717
243
  @self.app.route("/<path:path>")
718
- def serve_static(path):
244
+ def serve_static(path: str):
719
245
  """
720
246
  Serve static files (JS, CSS, assets, etc.).
721
247
  """
@@ -735,6 +261,9 @@ class WebUI:
735
261
  return send_from_directory(self.build_dir, path)
736
262
  return send_file(self.build_dir / "index.html")
737
263
 
264
+ # Register artifacts after core routes
265
+ register_artifacts_routes(self.app, self)
266
+
738
267
  def add_image_directory(self, directory: str):
739
268
  """
740
269
  Add a directory to scan for images.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pumaguard
3
- Version: 20.post172
3
+ Version: 20.post189
4
4
  Author-email: Nicolas Bock <nicolasbock@gmail.com>
5
5
  Project-URL: Homepage, http://pumaguard.rtfd.io/
6
6
  Project-URL: Repository, https://github.com/PEEC-Nature-Youth-Group/pumaguard