diff --git a/av_tools/av_tools/doctype/av_tools_settings/av_tools_settings.json b/av_tools/av_tools/doctype/av_tools_settings/av_tools_settings.json index 7b85f4d..6eb63f7 100644 --- a/av_tools/av_tools/doctype/av_tools_settings/av_tools_settings.json +++ b/av_tools/av_tools/doctype/av_tools_settings/av_tools_settings.json @@ -28,6 +28,13 @@ "enable_trade_in", "item_remaining_qty_section", "enable_validate_item_remaining_qty", + "camera_capture_section", + "enable_camera_capture_override", + "camera_capture_column_break", + "camera_capture_ideal_width", + "camera_capture_ideal_height", + "camera_capture_min_width", + "camera_capture_min_height", "parallel_approval_section", "enable_multi_approval_document", "approval_doctype" @@ -173,6 +180,55 @@ "fieldname": "enable_validate_item_remaining_qty", "fieldtype": "Check", "label": "Enable Item Remaining Qty Validation" + }, + { + "fieldname": "camera_capture_section", + "fieldtype": "Section Break", + "label": "Camera Capture" + }, + { + "default": "0", + "description": "Improve browser camera capture by applying AV Tools camera constraints before a photo is taken.", + "fieldname": "enable_camera_capture_override", + "fieldtype": "Check", + "label": "Enable Camera Capture Override" + }, + { + "depends_on": "eval:doc.enable_camera_capture_override == 1", + "fieldname": "camera_capture_column_break", + "fieldtype": "Column Break" + }, + { + "default": "1920", + "depends_on": "eval:doc.enable_camera_capture_override == 1", + "description": "Preferred capture width requested from the browser camera stream.", + "fieldname": "camera_capture_ideal_width", + "fieldtype": "Int", + "label": "Ideal Width" + }, + { + "default": "1080", + "depends_on": "eval:doc.enable_camera_capture_override == 1", + "description": "Preferred capture height requested from the browser camera stream.", + "fieldname": "camera_capture_ideal_height", + "fieldtype": "Int", + "label": "Ideal Height" + }, + { + "default": "0", + "depends_on": "eval:doc.enable_camera_capture_override == 1", + "description": "Optional minimum width. Leave 0 to avoid enforcing a minimum width.", + "fieldname": "camera_capture_min_width", + "fieldtype": "Int", + "label": "Minimum Width" + }, + { + "default": "0", + "depends_on": "eval:doc.enable_camera_capture_override == 1", + "description": "Optional minimum height. Leave 0 to avoid enforcing a minimum height.", + "fieldname": "camera_capture_min_height", + "fieldtype": "Int", + "label": "Minimum Height" }, { "fieldname": "parallel_approval_section", diff --git a/av_tools/av_tools/doctype/av_tools_settings/test_av_tools_settings.py b/av_tools/av_tools/doctype/av_tools_settings/test_av_tools_settings.py index 5a17095..3d60095 100644 --- a/av_tools/av_tools/doctype/av_tools_settings/test_av_tools_settings.py +++ b/av_tools/av_tools/doctype/av_tools_settings/test_av_tools_settings.py @@ -1,12 +1,17 @@ import json from contextlib import contextmanager +from typing import ClassVar from unittest.mock import patch import frappe +from erpnext.accounts.test.accounts_mixin import AccountsTestMixin +from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order +from erpnext.stock.get_item_details import get_item_details as original_get_item_details from frappe.core.doctype.user.test_user import test_user from frappe.tests.utils import FrappeTestCase from av_tools.av_tools.doctype.av_tools_settings.av_tools_settings import AVToolsSettings +from av_tools.av_tools_hooks.capture import get_capture_settings from av_tools.av_tools_hooks.generic_erp_behavior_overrides import ( close_or_unclose_purchase_orders, get_item_details, @@ -15,20 +20,24 @@ ) from av_tools.patches.v1_0.migrate_generic_erp_behavior_overrides import ( SETTINGS_DOCTYPE, +) +from av_tools.patches.v1_0.migrate_generic_erp_behavior_overrides import ( execute as migrate_generic_settings, ) -from erpnext.accounts.test.accounts_mixin import AccountsTestMixin -from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order -from erpnext.stock.get_item_details import get_item_details as original_get_item_details class TestAVToolsSettings(AccountsTestMixin, FrappeTestCase): - settings_defaults = { + settings_defaults: ClassVar[dict[str, object]] = { "allow_reopen_of_po_based_on_role": 0, "role_to_reopen_po": "", "allow_reopen_of_material_request_based_on_role": 0, "role_to_reopen_material_request": "", "override_sales_invoice_qty": 0, + "enable_camera_capture_override": 0, + "camera_capture_ideal_width": 1920, + "camera_capture_ideal_height": 1080, + "camera_capture_min_width": 0, + "camera_capture_min_height": 0, } test_company_name = "Rubis Technical Services Limited" test_supplier_name = "AV Tools Test Supplier" @@ -147,12 +156,15 @@ def test_patch_migrates_settings_idempotently(self): "override_sales_invoice_qty": 1, } - with patch( - "av_tools.patches.v1_0.migrate_generic_erp_behavior_overrides.source_settings_doctype_exists", - return_value=True, - ), patch( - "av_tools.patches.v1_0.migrate_generic_erp_behavior_overrides.get_source_values", - return_value=expected_values, + with ( + patch( + "av_tools.patches.v1_0.migrate_generic_erp_behavior_overrides.source_settings_doctype_exists", + return_value=True, + ), + patch( + "av_tools.patches.v1_0.migrate_generic_erp_behavior_overrides.get_source_values", + return_value=expected_values, + ), ): migrate_generic_settings() migrate_generic_settings() @@ -324,3 +336,45 @@ def make_item_details_args(self): "ignore_pricing_rule": 1, "qty": 1, } + + +class TestAVToolsCaptureSettings(FrappeTestCase): + settings_defaults: ClassVar[dict[str, object]] = { + "enable_camera_capture_override": 0, + "camera_capture_ideal_width": 1920, + "camera_capture_ideal_height": 1080, + "camera_capture_min_width": 0, + "camera_capture_min_height": 0, + } + + def setUp(self): + frappe.set_user("Administrator") + self.set_settings() + + def tearDown(self): + frappe.set_user("Administrator") + self.set_settings() + frappe.db.rollback() + super().tearDown() + + def set_settings(self, **overrides): + values = {**self.settings_defaults, **overrides} + for fieldname, value in values.items(): + frappe.db.set_single_value(SETTINGS_DOCTYPE, fieldname, value) + + def test_get_capture_settings_uses_single_doctype_values(self): + self.set_settings( + enable_camera_capture_override=1, + camera_capture_ideal_width=2560, + camera_capture_ideal_height=1440, + camera_capture_min_width=1280, + camera_capture_min_height=720, + ) + + settings = get_capture_settings() + + self.assertTrue(settings["enabled"]) + self.assertEqual(settings["ideal_width"], 2560) + self.assertEqual(settings["ideal_height"], 1440) + self.assertEqual(settings["min_width"], 1280) + self.assertEqual(settings["min_height"], 720) diff --git a/av_tools/av_tools_hooks/capture.py b/av_tools/av_tools_hooks/capture.py new file mode 100644 index 0000000..efc3efd --- /dev/null +++ b/av_tools/av_tools_hooks/capture.py @@ -0,0 +1,48 @@ +import frappe +from frappe.utils import cint + +SETTINGS_DOCTYPE = "AV Tools Settings" +DEFAULT_CAPTURE_SETTINGS = { + "enabled": False, + "ideal_width": 1920, + "ideal_height": 1080, + "min_width": 0, + "min_height": 0, +} + + +def _sanitize_positive_int(value, default=0): + value = cint(value) + if value > 0: + return value + return default + + +def _get_capture_settings(): + settings = DEFAULT_CAPTURE_SETTINGS.copy() + + try: + values = frappe.db.get_singles_dict(SETTINGS_DOCTYPE) or {} + except Exception: + return settings + + settings["enabled"] = bool(cint(values.get("enable_camera_capture_override"))) + settings["ideal_width"] = _sanitize_positive_int( + values.get("camera_capture_ideal_width"), DEFAULT_CAPTURE_SETTINGS["ideal_width"] + ) + settings["ideal_height"] = _sanitize_positive_int( + values.get("camera_capture_ideal_height"), DEFAULT_CAPTURE_SETTINGS["ideal_height"] + ) + settings["min_width"] = _sanitize_positive_int( + values.get("camera_capture_min_width"), DEFAULT_CAPTURE_SETTINGS["min_width"] + ) + settings["min_height"] = _sanitize_positive_int( + values.get("camera_capture_min_height"), DEFAULT_CAPTURE_SETTINGS["min_height"] + ) + + return settings + + +@frappe.whitelist() +def get_capture_settings(): + return _get_capture_settings() diff --git a/av_tools/hooks.py b/av_tools/hooks.py index a596e43..3a28e34 100644 --- a/av_tools/hooks.py +++ b/av_tools/hooks.py @@ -30,6 +30,7 @@ "/assets/av_tools/js/financial_statements_override.js", "/assets/av_tools/js/ai_assist.js", "/assets/av_tools/js/parallel_approval.js", + "av_tools.bundle.js", ] app_include_css = "/assets/av_tools/css/theme.css" diff --git a/av_tools/public/av_tools.bundle.js b/av_tools/public/av_tools.bundle.js index b2b1247..74439ca 100644 --- a/av_tools/public/av_tools.bundle.js +++ b/av_tools/public/av_tools.bundle.js @@ -1 +1,2 @@ import "./js/financial_statements_override"; +import "./js/capture_override"; diff --git a/av_tools/public/js/capture_override.js b/av_tools/public/js/capture_override.js new file mode 100644 index 0000000..66a50c7 --- /dev/null +++ b/av_tools/public/js/capture_override.js @@ -0,0 +1,190 @@ +(function () { + var defaultCaptureSettings = { + enabled: 0, + ideal_width: 1920, + ideal_height: 1080, + min_width: 0, + min_height: 0, + }; + var captureSettings = null; + var captureSettingsRequest = null; + + function parsePositiveInt(value, fallback) { + var parsed = parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; + } + + function normalizeCaptureSettings(settings) { + return { + enabled: Boolean(Number(settings && settings.enabled)), + ideal_width: parsePositiveInt( + settings && settings.ideal_width, + defaultCaptureSettings.ideal_width + ), + ideal_height: parsePositiveInt( + settings && settings.ideal_height, + defaultCaptureSettings.ideal_height + ), + min_width: parsePositiveInt( + settings && settings.min_width, + defaultCaptureSettings.min_width + ), + min_height: parsePositiveInt( + settings && settings.min_height, + defaultCaptureSettings.min_height + ), + }; + } + + function loadCaptureSettings() { + if (captureSettings) { + return Promise.resolve(captureSettings); + } + + if (captureSettingsRequest) { + return captureSettingsRequest; + } + + captureSettingsRequest = new Promise(function (resolve) { + frappe.call({ + method: "av_tools.av_tools_hooks.capture.get_capture_settings", + callback: function (response) { + captureSettings = normalizeCaptureSettings( + response.message || defaultCaptureSettings + ); + resolve(captureSettings); + }, + error: function () { + captureSettings = normalizeCaptureSettings(defaultCaptureSettings); + resolve(captureSettings); + }, + }); + }).finally(function () { + captureSettingsRequest = null; + }); + + return captureSettingsRequest; + } + + function buildConstraints(capture, settings) { + var video = { + facingMode: { + ideal: capture.facing_mode, + }, + }; + + if (settings.min_width || settings.ideal_width) { + video.width = {}; + if (settings.min_width) { + video.width.min = settings.min_width; + } + if (settings.ideal_width) { + video.width.ideal = settings.ideal_width; + } + } + + if (settings.min_height || settings.ideal_height) { + video.height = {}; + if (settings.min_height) { + video.height.min = settings.min_height; + } + if (settings.ideal_height) { + video.height.ideal = settings.ideal_height; + } + } + + return { + video: video, + audio: false, + }; + } + + function renderStreamWithConstraints(capture, constraints) { + return navigator.mediaDevices.getUserMedia(constraints).then(function (stream) { + capture.stream = stream; + capture.dialog.custom_actions.empty(); + capture.dialog.get_primary_btn().off("click"); + capture.setup_take_photo_action(); + capture.setup_preview_action(); + capture.setup_toggle_camera(); + + capture.$template.find(".fc-stream-container").show(); + capture.$template.find(".fc-preview-container").hide(); + capture.video = capture.$template.find("video")[0]; + capture.video.srcObject = capture.stream; + capture.video.load(); + + var playPromise = capture.video.play(); + if (playPromise && typeof playPromise.then === "function") { + return playPromise; + } + + return undefined; + }); + } + + function installCaptureOverride() { + if ( + !frappe.ui || + !frappe.ui.Capture || + frappe.ui.Capture.__av_tools_capture_override_installed + ) { + return Boolean(frappe.ui && frappe.ui.Capture); + } + + var proto = frappe.ui.Capture.prototype; + var originalShow = proto.show; + var originalRenderStream = proto.render_stream; + + proto.show = function () { + if (!frappe.is_mobile()) { + return originalShow.call(this); + } + + if (!captureSettings) { + loadCaptureSettings(); + return originalShow.call(this); + } + + if (!captureSettings.enabled) { + return originalShow.call(this); + } + + this.build_dialog(); + return this.show_for_desktop(); + }; + + proto.render_stream = function () { + var me = this; + + return loadCaptureSettings().then(function (settings) { + if (!settings.enabled) { + return originalRenderStream.call(me); + } + + return renderStreamWithConstraints(me, buildConstraints(me, settings)); + }); + }; + + frappe.ui.Capture.__av_tools_capture_override_installed = true; + return true; + } + + function retryInstall() { + loadCaptureSettings(); + + if (installCaptureOverride()) return; + + var attempts = 0; + var interval = setInterval(function () { + attempts += 1; + + if (installCaptureOverride() || attempts > 50) { + clearInterval(interval); + } + }, 200); + } + + $(retryInstall); + $(document).on("app_ready startup", retryInstall); +})();