diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 32e6881e..d20fe6d0 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -22,7 +22,7 @@ repos:
rev: v2.4.1
hooks:
- id: codespell
- args: ["--ignore-words-list", "notin"]
+ args: ['--ignore-words-list', 'notin']
exclude: 'yarn.lock|poetry.lock'
additional_dependencies:
- tomli
diff --git a/beam/beam/custom/user.json b/beam/beam/custom/user.json
index c7efa884..9075ecb1 100644
--- a/beam/beam/custom/user.json
+++ b/beam/beam/custom/user.json
@@ -1,85 +1,85 @@
{
- "custom_fields": [
- {
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "default": null,
- "dt": "User",
- "fetch_if_empty": 0,
- "fieldname": "barcode_section",
- "fieldtype": "Section Break",
- "hidden": 0,
- "hide_border": 0,
- "hide_days": 0,
- "hide_seconds": 0,
- "idx": 26,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_preview": 0,
- "in_standard_filter": 0,
- "insert_after": "mobile_no",
- "label": "Barcodes",
- "length": 0,
- "module": "BEAM",
- "name": "User-barcode_section",
- "no_copy": 0,
- "non_negative": 0,
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "translatable": 0,
- "unique": 0
- },
- {
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "default": null,
- "dt": "User",
- "fetch_if_empty": 0,
- "fieldname": "barcodes",
- "fieldtype": "Table",
- "hidden": 0,
- "hide_border": 0,
- "hide_days": 0,
- "hide_seconds": 0,
- "idx": 27,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_preview": 0,
- "in_standard_filter": 0,
- "insert_after": "barcode_section",
- "length": 0,
- "module": "BEAM",
- "name": "User-barcodes",
- "no_copy": 0,
- "non_negative": 0,
- "options": "Item Barcode",
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "translatable": 0,
- "unique": 0
- }
- ],
- "doctype": "User",
- "property_setters": [],
- "sync_on_migrate": 1
-}
\ No newline at end of file
+ "custom_fields": [
+ {
+ "allow_in_quick_entry": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "default": null,
+ "dt": "User",
+ "fetch_if_empty": 0,
+ "fieldname": "barcode_section",
+ "fieldtype": "Section Break",
+ "hidden": 0,
+ "hide_border": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "idx": 26,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "mobile_no",
+ "label": "Barcodes",
+ "length": 0,
+ "module": "BEAM",
+ "name": "User-barcode_section",
+ "no_copy": 0,
+ "non_negative": 0,
+ "permlevel": 0,
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "translatable": 0,
+ "unique": 0
+ },
+ {
+ "allow_in_quick_entry": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "default": null,
+ "dt": "User",
+ "fetch_if_empty": 0,
+ "fieldname": "barcodes",
+ "fieldtype": "Table",
+ "hidden": 0,
+ "hide_border": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "idx": 27,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "barcode_section",
+ "length": 0,
+ "module": "BEAM",
+ "name": "User-barcodes",
+ "no_copy": 0,
+ "non_negative": 0,
+ "options": "Item Barcode",
+ "permlevel": 0,
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "translatable": 0,
+ "unique": 0
+ }
+ ],
+ "doctype": "User",
+ "property_setters": [],
+ "sync_on_migrate": 1
+}
diff --git a/beam/beam/demand/sqlite.py b/beam/beam/demand/sqlite.py
index e01f1e70..378ffb94 100644
--- a/beam/beam/demand/sqlite.py
+++ b/beam/beam/demand/sqlite.py
@@ -3,6 +3,7 @@
import pathlib
import sqlite3
+from typing import Any
import frappe
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
@@ -153,7 +154,7 @@ def reset_receiving_db() -> None:
cursor.execute("DELETE FROM receiving")
-def dict_factory(cursor: sqlite3.Cursor, row: sqlite3.Row) -> frappe._dict:
+def dict_factory(cursor: sqlite3.Cursor, row: tuple[Any, ...]) -> frappe._dict:
_dict = frappe._dict()
for idx, col in enumerate(cursor.description):
_dict[col[0]] = row[idx]
diff --git a/beam/tests/mobile/test_camera_scanner.py b/beam/tests/mobile/test_camera_scanner.py
new file mode 100644
index 00000000..75b9e4ec
--- /dev/null
+++ b/beam/tests/mobile/test_camera_scanner.py
@@ -0,0 +1,164 @@
+# Copyright (c) 2024, AgriTheory and contributors
+# For license information, please see license.txt
+
+# Tests for camera scanner component.
+
+
+from playwright.sync_api import expect
+
+
+def test_camera_scanner_button_hidden(page, setup):
+ page.add_init_script(
+ """
+ Object.defineProperty(navigator, 'mediaDevices', {
+ value: {
+ enumerateDevices: async () => {
+ return []; // No cameras available
+ },
+ getUserMedia: async (constraints) => {
+ const canvas = document.createElement('canvas');
+ canvas.width = 640;
+ canvas.height = 480;
+ return canvas.captureStream(30);
+ }
+ },
+ });
+ """
+ )
+
+ page.get_by_text("Move").click()
+ page.wait_for_url("**/beam#/move")
+
+ # Wait for component to check permissions and decide to hide itself
+ page.wait_for_timeout(1500)
+
+ # The component should not be visible when no cameras are found
+ camera_scanner = page.locator(".camera-scanner")
+ expect(camera_scanner).to_have_count(0)
+
+
+def test_camera_scanner_button_visible(page, setup):
+ page.add_init_script(
+ """
+ navigator.mediaDevices.enumerateDevices = async () => {
+ return [
+ {
+ kind: 'videoinput',
+ deviceId: 'mock-camera-1',
+ label: 'Mock Camera',
+ groupId: 'mock-group'
+ }
+ ];
+ };
+ """
+ )
+
+ page.get_by_text("Move").click()
+ page.wait_for_url("**/beam#/move")
+
+ page.wait_for_timeout(1000)
+
+ camera_button = page.locator("button:has-text('Open Camera')")
+ expect(camera_button).to_be_visible()
+
+
+def test_camera_scanner_activates_camera(page, setup):
+ page.add_init_script(
+ """
+ window.getUserMediaCalled = false;
+ window.getUserMediaConstraints = null;
+
+ Object.defineProperty(navigator, 'mediaDevices', {
+ value: {
+ enumerateDevices: async () => {
+ return [
+ {
+ kind: 'videoinput',
+ deviceId: 'mock-camera-1',
+ label: 'Mock Camera',
+ groupId: 'mock-group'
+ }
+ ];
+ },
+ getUserMedia: async (constraints) => {
+ window.getUserMediaCalled = true;
+ window.getUserMediaConstraints = constraints;
+ // Return a minimal fake stream
+ const canvas = document.createElement('canvas');
+ canvas.width = 640;
+ canvas.height = 480;
+ return canvas.captureStream(30);
+ }
+ },
+ writable: false,
+ configurable: true
+ });
+ """
+ )
+
+ page.get_by_text("Move").click()
+ page.wait_for_url("**/beam#/move")
+ page.wait_for_timeout(2000)
+
+ was_called_before = page.evaluate("window.getUserMediaCalled")
+ assert was_called_before == False, "getUserMedia should not be called before clicking button"
+
+ camera_button = page.locator("button:has-text('Open Camera')")
+ expect(camera_button).to_be_enabled(timeout=10000)
+
+ camera_button.click()
+ page.wait_for_timeout(1000)
+
+ was_called_after = page.evaluate("window.getUserMediaCalled")
+ assert was_called_after == True, "getUserMedia should be called after clicking 'Open Camera'"
+
+ # Verify correct constraints (should request video with back camera)
+ constraints = page.evaluate("window.getUserMediaConstraints")
+ assert constraints is not None, "getUserMedia should receive constraints"
+ assert "video" in constraints, "Should request video stream"
+ assert constraints["video"]["facingMode"] == "environment", "Should request back camera"
+
+
+def test_camera_scanner_permission_denied(page, setup):
+ # Mock camera APIs to simulate permission denial
+ page.add_init_script(
+ """
+ Object.defineProperty(navigator, 'mediaDevices', {
+ value: {
+ enumerateDevices: async () => {
+ return [
+ {
+ kind: 'videoinput',
+ deviceId: 'mock-camera-1',
+ label: 'Mock Camera',
+ groupId: 'mock-group'
+ }
+ ];
+ },
+ getUserMedia: async (constraints) => {
+ const error = new Error('Permission denied');
+ error.name = 'NotAllowedError';
+ throw error;
+ }
+ },
+ writable: false,
+ configurable: true
+ });
+ """
+ )
+
+ page.get_by_text("Move").click()
+ page.wait_for_url("**/beam#/move")
+ page.wait_for_timeout(2000)
+
+ camera_button = page.locator("button:has-text('Open Camera')")
+ expect(camera_button).to_be_enabled(timeout=10000)
+
+ camera_button.click()
+
+ page.wait_for_timeout(500)
+
+ # Verify error message is displayed
+ error_message = page.locator(".error-message")
+ expect(error_message).to_be_visible()
+ expect(error_message).to_contain_text("Permission denied")
diff --git a/beam/www/beam/components/CameraScanner.vue b/beam/www/beam/components/CameraScanner.vue
new file mode 100644
index 00000000..a3546a6b
--- /dev/null
+++ b/beam/www/beam/components/CameraScanner.vue
@@ -0,0 +1,210 @@
+
+ Last scanned: {{ lastScanned }}