diff --git a/.github/workflows/weld-app.yaml b/.github/workflows/weld-app.yaml index 45c51c2..c65e4ad 100644 --- a/.github/workflows/weld-app.yaml +++ b/.github/workflows/weld-app.yaml @@ -11,6 +11,7 @@ jobs: POETRY_VERSION: "1.8.2" WELD_APP_CONFIG: ${{ vars.WELD_APP_SAMPLE_CONFIG }} WELD_APP_CAMERA_CONFIG: ${{ vars.WELD_APP_CAMERA_SAMPLE_CONFIG }} + WELD_APP_DATABASE_CONFIG: ${{ vars.WELD_APP_DATABASE_SAMPLE_CONFIG }} GROUNDLIGHT_API_TOKEN: ${{ secrets.GROUNDLIGHT_API_TOKEN }} steps: diff --git a/README.md b/README.md index 6026896..4e9d1ad 100644 --- a/README.md +++ b/README.md @@ -73,10 +73,57 @@ The app requires the following environment variables: Each `camera_config` entries will need to match the configuration settings for `FrameGrab` so the cameras can be intiialized correctly. +- `WELD_APP_DATABASE_CONFIG`: This is the database config for fetching data from Google API Service. + +```json +{ + "enabled": true, + "service_account": { + "type": "service_account", + "project_id": "PROJECT_ID_HERE", + "private_key_id": "PRIVATE_KEY_ID_HERE", + "private_key": "PRIVATE_KEY_HERE", + "client_email": "CLIENT_EMAIL_HERE", + "client_id": "CLIENT_ID_HERE", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "CLIENT_CERT_URL_HERE", + "universe_domain": "googleapis.com" + }, + "database_id": "YOUR_DATABASE_ID_HERE", + "database_range": "Sheet1!A2:C" +} +``` + +The `service_account` section should match the credential JSON file downloaded from Google Cloud. + +- `WELD_APP_SUPERVISOR_PASSWORD`: The supervisor password to lock/unlock the Jig Lock, default to None if not set + +- `WELD_APP_DEVICE_ID`: Set the device ID for this particular device, should set this unique for each device for easier debugging + - `GROUNDLIGHT_API_TOKEN`: Groundlight API Token - `LAUNCH_URL`: Set this to `http://router/hub/launch/1` to ensure that the device automatically redirects to the application main page when it is ready +### Creating Supervisor Password + +If the you would like to only allow supervisors to lock/unlock the Jig Lock, you need to set the environment variable `WELD_APP_SUPERVISOR_PASSWORD` with the hashed password. + +The hashed password can be created with the `generate_hash.py` script by running the following command: + +```bash +poetry run python generate_hash.py PASSWORD +``` + +Copy the hashed password generated from the script to the environment variable: + +```bash +export WELD_APP_SUPERVISOR_PASSWORD="HASHED_PASSWORD" +``` + +For balena deployment just copy the hashed password into the `Device Variables` tab. + ## Local Testing To run the app on your local machine just run this command: diff --git a/generate_hash.py b/generate_hash.py new file mode 100644 index 0000000..7f299a4 --- /dev/null +++ b/generate_hash.py @@ -0,0 +1,22 @@ +import bcrypt +import argparse + + +def hash_password(password): + # Generate a salt + salt = bcrypt.gensalt() + # Generate the hashed password + hashed_password = bcrypt.hashpw(password.encode(), salt) + return hashed_password.decode() # Store as a string + + +parser = argparse.ArgumentParser(description="Hash a password") +parser.add_argument("password", help="The password to hash") +args = parser.parse_args() + +password = args.password + +# Generate hashed password +hashed_password = hash_password(password) + +print(f"Hashed password: {hashed_password}") diff --git a/poetry.lock b/poetry.lock index 90a7de1..3d31008 100644 --- a/poetry.lock +++ b/poetry.lock @@ -45,6 +45,44 @@ docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphi tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] +[[package]] +name = "bcrypt" +version = "4.2.1" +description = "Modern password hashing for your software and your servers" +optional = false +python-versions = ">=3.7" +files = [ + {file = "bcrypt-4.2.1-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:1340411a0894b7d3ef562fb233e4b6ed58add185228650942bdc885362f32c17"}, + {file = "bcrypt-4.2.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1ee315739bc8387aa36ff127afc99120ee452924e0df517a8f3e4c0187a0f5f"}, + {file = "bcrypt-4.2.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dbd0747208912b1e4ce730c6725cb56c07ac734b3629b60d4398f082ea718ad"}, + {file = "bcrypt-4.2.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:aaa2e285be097050dba798d537b6efd9b698aa88eef52ec98d23dcd6d7cf6fea"}, + {file = "bcrypt-4.2.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:76d3e352b32f4eeb34703370e370997065d28a561e4a18afe4fef07249cb4396"}, + {file = "bcrypt-4.2.1-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:b7703ede632dc945ed1172d6f24e9f30f27b1b1a067f32f68bf169c5f08d0425"}, + {file = "bcrypt-4.2.1-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:89df2aea2c43be1e1fa066df5f86c8ce822ab70a30e4c210968669565c0f4685"}, + {file = "bcrypt-4.2.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:04e56e3fe8308a88b77e0afd20bec516f74aecf391cdd6e374f15cbed32783d6"}, + {file = "bcrypt-4.2.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cfdf3d7530c790432046c40cda41dfee8c83e29482e6a604f8930b9930e94139"}, + {file = "bcrypt-4.2.1-cp37-abi3-win32.whl", hash = "sha256:adadd36274510a01f33e6dc08f5824b97c9580583bd4487c564fc4617b328005"}, + {file = "bcrypt-4.2.1-cp37-abi3-win_amd64.whl", hash = "sha256:8c458cd103e6c5d1d85cf600e546a639f234964d0228909d8f8dbeebff82d526"}, + {file = "bcrypt-4.2.1-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:8ad2f4528cbf0febe80e5a3a57d7a74e6635e41af1ea5675282a33d769fba413"}, + {file = "bcrypt-4.2.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:909faa1027900f2252a9ca5dfebd25fc0ef1417943824783d1c8418dd7d6df4a"}, + {file = "bcrypt-4.2.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cde78d385d5e93ece5479a0a87f73cd6fa26b171c786a884f955e165032b262c"}, + {file = "bcrypt-4.2.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:533e7f3bcf2f07caee7ad98124fab7499cb3333ba2274f7a36cf1daee7409d99"}, + {file = "bcrypt-4.2.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:687cf30e6681eeda39548a93ce9bfbb300e48b4d445a43db4298d2474d2a1e54"}, + {file = "bcrypt-4.2.1-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:041fa0155c9004eb98a232d54da05c0b41d4b8e66b6fc3cb71b4b3f6144ba837"}, + {file = "bcrypt-4.2.1-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f85b1ffa09240c89aa2e1ae9f3b1c687104f7b2b9d2098da4e923f1b7082d331"}, + {file = "bcrypt-4.2.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c6f5fa3775966cca251848d4d5393ab016b3afed251163c1436fefdec3b02c84"}, + {file = "bcrypt-4.2.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:807261df60a8b1ccd13e6599c779014a362ae4e795f5c59747f60208daddd96d"}, + {file = "bcrypt-4.2.1-cp39-abi3-win32.whl", hash = "sha256:b588af02b89d9fad33e5f98f7838bf590d6d692df7153647724a7f20c186f6bf"}, + {file = "bcrypt-4.2.1-cp39-abi3-win_amd64.whl", hash = "sha256:e84e0e6f8e40a242b11bce56c313edc2be121cec3e0ec2d76fce01f6af33c07c"}, + {file = "bcrypt-4.2.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:76132c176a6d9953cdc83c296aeaed65e1a708485fd55abf163e0d9f8f16ce0e"}, + {file = "bcrypt-4.2.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e158009a54c4c8bc91d5e0da80920d048f918c61a581f0a63e4e93bb556d362f"}, + {file = "bcrypt-4.2.1.tar.gz", hash = "sha256:6765386e3ab87f569b276988742039baab087b2cdb01e809d74e74503c2faafe"}, +] + +[package.extras] +tests = ["pytest (>=3.2.1,!=3.3.0)"] +typecheck = ["mypy"] + [[package]] name = "blinker" version = "1.9.0" @@ -56,6 +94,17 @@ files = [ {file = "blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf"}, ] +[[package]] +name = "cachetools" +version = "5.5.0" +description = "Extensible memoizing collections and decorators" +optional = false +python-versions = ">=3.7" +files = [ + {file = "cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292"}, + {file = "cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a"}, +] + [[package]] name = "certifi" version = "2024.8.30" @@ -304,6 +353,107 @@ files = [ {file = "frozendict-2.4.6.tar.gz", hash = "sha256:df7cd16470fbd26fc4969a208efadc46319334eb97def1ddf48919b351192b8e"}, ] +[[package]] +name = "google-api-core" +version = "2.24.0" +description = "Google API client core library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google_api_core-2.24.0-py3-none-any.whl", hash = "sha256:10d82ac0fca69c82a25b3efdeefccf6f28e02ebb97925a8cce8edbfe379929d9"}, + {file = "google_api_core-2.24.0.tar.gz", hash = "sha256:e255640547a597a4da010876d333208ddac417d60add22b6851a0c66a831fcaf"}, +] + +[package.dependencies] +google-auth = ">=2.14.1,<3.0.dev0" +googleapis-common-protos = ">=1.56.2,<2.0.dev0" +proto-plus = [ + {version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""}, + {version = ">=1.22.3,<2.0.0dev", markers = "python_version < \"3.13\""}, +] +protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" +requests = ">=2.18.0,<3.0.0.dev0" + +[package.extras] +async-rest = ["google-auth[aiohttp] (>=2.35.0,<3.0.dev0)"] +grpc = ["grpcio (>=1.33.2,<2.0dev)", "grpcio (>=1.49.1,<2.0dev)", "grpcio-status (>=1.33.2,<2.0.dev0)", "grpcio-status (>=1.49.1,<2.0.dev0)"] +grpcgcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] +grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] + +[[package]] +name = "google-api-python-client" +version = "2.156.0" +description = "Google API Client Library for Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google_api_python_client-2.156.0-py2.py3-none-any.whl", hash = "sha256:6352185c505e1f311f11b0b96c1b636dcb0fec82cd04b80ac5a671ac4dcab339"}, + {file = "google_api_python_client-2.156.0.tar.gz", hash = "sha256:b809c111ded61716a9c1c7936e6899053f13bae3defcdfda904bd2ca68065b9c"}, +] + +[package.dependencies] +google-api-core = ">=1.31.5,<2.0.dev0 || >2.3.0,<3.0.0.dev0" +google-auth = ">=1.32.0,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0.dev0" +google-auth-httplib2 = ">=0.2.0,<1.0.0" +httplib2 = ">=0.19.0,<1.dev0" +uritemplate = ">=3.0.1,<5" + +[[package]] +name = "google-auth" +version = "2.37.0" +description = "Google Authentication Library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google_auth-2.37.0-py2.py3-none-any.whl", hash = "sha256:42664f18290a6be591be5329a96fe30184be1a1badb7292a7f686a9659de9ca0"}, + {file = "google_auth-2.37.0.tar.gz", hash = "sha256:0054623abf1f9c83492c63d3f47e77f0a544caa3d40b2d98e099a611c2dd5d00"}, +] + +[package.dependencies] +cachetools = ">=2.0.0,<6.0" +pyasn1-modules = ">=0.2.1" +rsa = ">=3.1.4,<5" + +[package.extras] +aiohttp = ["aiohttp (>=3.6.2,<4.0.0.dev0)", "requests (>=2.20.0,<3.0.0.dev0)"] +enterprise-cert = ["cryptography", "pyopenssl"] +pyjwt = ["cryptography (>=38.0.3)", "pyjwt (>=2.0)"] +pyopenssl = ["cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] +reauth = ["pyu2f (>=0.1.5)"] +requests = ["requests (>=2.20.0,<3.0.0.dev0)"] + +[[package]] +name = "google-auth-httplib2" +version = "0.2.0" +description = "Google Authentication Library: httplib2 transport" +optional = false +python-versions = "*" +files = [ + {file = "google-auth-httplib2-0.2.0.tar.gz", hash = "sha256:38aa7badf48f974f1eb9861794e9c0cb2a0511a4ec0679b1f886d108f5640e05"}, + {file = "google_auth_httplib2-0.2.0-py2.py3-none-any.whl", hash = "sha256:b65a0a2123300dd71281a7bf6e64d65a0759287df52729bdd1ae2e47dc311a3d"}, +] + +[package.dependencies] +google-auth = "*" +httplib2 = ">=0.19.0" + +[[package]] +name = "googleapis-common-protos" +version = "1.66.0" +description = "Common protobufs used in Google APIs" +optional = false +python-versions = ">=3.7" +files = [ + {file = "googleapis_common_protos-1.66.0-py2.py3-none-any.whl", hash = "sha256:d7abcd75fabb2e0ec9f74466401f6c119a0b498e27370e9be4c94cb7e382b8ed"}, + {file = "googleapis_common_protos-1.66.0.tar.gz", hash = "sha256:c3e7b33d15fdca5374cc0a7346dd92ffa847425cc4ea941d970f13680052ec8c"}, +] + +[package.dependencies] +protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" + +[package.extras] +grpc = ["grpcio (>=1.44.0,<2.0.0.dev0)"] + [[package]] name = "groundlight" version = "0.21.1" @@ -325,6 +475,20 @@ requests = ">=2.28.2,<3.0.0" typer = ">=0.12.3,<0.13.0" urllib3 = ">=1.26.9,<2.0.0" +[[package]] +name = "httplib2" +version = "0.22.0" +description = "A comprehensive HTTP client library." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "httplib2-0.22.0-py3-none-any.whl", hash = "sha256:14ae0a53c1ba8f3d37e9e27cf37eabb0fb9980f435ba405d546948b009dd64dc"}, + {file = "httplib2-0.22.0.tar.gz", hash = "sha256:d7a10bc5ef5ab08322488bde8c726eeee5c8618723fdb399597ec58f3d82df81"}, +] + +[package.dependencies] +pyparsing = {version = ">=2.4.2,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.0.2 || >3.0.2,<3.0.3 || >3.0.3,<4", markers = "python_version > \"3.0\""} + [[package]] name = "idna" version = "3.10" @@ -914,6 +1078,68 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "proto-plus" +version = "1.25.0" +description = "Beautiful, Pythonic protocol buffers." +optional = false +python-versions = ">=3.7" +files = [ + {file = "proto_plus-1.25.0-py3-none-any.whl", hash = "sha256:c91fc4a65074ade8e458e95ef8bac34d4008daa7cce4a12d6707066fca648961"}, + {file = "proto_plus-1.25.0.tar.gz", hash = "sha256:fbb17f57f7bd05a68b7707e745e26528b0b3c34e378db91eef93912c54982d91"}, +] + +[package.dependencies] +protobuf = ">=3.19.0,<6.0.0dev" + +[package.extras] +testing = ["google-api-core (>=1.31.5)"] + +[[package]] +name = "protobuf" +version = "5.29.2" +description = "" +optional = false +python-versions = ">=3.8" +files = [ + {file = "protobuf-5.29.2-cp310-abi3-win32.whl", hash = "sha256:c12ba8249f5624300cf51c3d0bfe5be71a60c63e4dcf51ffe9a68771d958c851"}, + {file = "protobuf-5.29.2-cp310-abi3-win_amd64.whl", hash = "sha256:842de6d9241134a973aab719ab42b008a18a90f9f07f06ba480df268f86432f9"}, + {file = "protobuf-5.29.2-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a0c53d78383c851bfa97eb42e3703aefdc96d2036a41482ffd55dc5f529466eb"}, + {file = "protobuf-5.29.2-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:494229ecd8c9009dd71eda5fd57528395d1eacdf307dbece6c12ad0dd09e912e"}, + {file = "protobuf-5.29.2-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:b6b0d416bbbb9d4fbf9d0561dbfc4e324fd522f61f7af0fe0f282ab67b22477e"}, + {file = "protobuf-5.29.2-cp38-cp38-win32.whl", hash = "sha256:e621a98c0201a7c8afe89d9646859859be97cb22b8bf1d8eacfd90d5bda2eb19"}, + {file = "protobuf-5.29.2-cp38-cp38-win_amd64.whl", hash = "sha256:13d6d617a2a9e0e82a88113d7191a1baa1e42c2cc6f5f1398d3b054c8e7e714a"}, + {file = "protobuf-5.29.2-cp39-cp39-win32.whl", hash = "sha256:36000f97ea1e76e8398a3f02936aac2a5d2b111aae9920ec1b769fc4a222c4d9"}, + {file = "protobuf-5.29.2-cp39-cp39-win_amd64.whl", hash = "sha256:2d2e674c58a06311c8e99e74be43e7f3a8d1e2b2fdf845eaa347fbd866f23355"}, + {file = "protobuf-5.29.2-py3-none-any.whl", hash = "sha256:fde4554c0e578a5a0bcc9a276339594848d1e89f9ea47b4427c80e5d72f90181"}, + {file = "protobuf-5.29.2.tar.gz", hash = "sha256:b2cc8e8bb7c9326996f0e160137b0861f1a82162502658df2951209d0cb0309e"}, +] + +[[package]] +name = "pyasn1" +version = "0.6.1" +description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, + {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.1" +description = "A collection of ASN.1-based protocols modules" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyasn1_modules-0.4.1-py3-none-any.whl", hash = "sha256:49bfa96b45a292b711e986f222502c1c9a5e1f4e568fc30e2574a6c7d07838fd"}, + {file = "pyasn1_modules-0.4.1.tar.gz", hash = "sha256:c28e2dbf9c06ad61c71a075c7e0f9fd0f1b0bb2d2ad4377f240d33ac2ab60a7c"}, +] + +[package.dependencies] +pyasn1 = ">=0.4.6,<0.7.0" + [[package]] name = "pydantic" version = "2.10.3" @@ -1060,6 +1286,20 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pyparsing" +version = "3.2.0" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pyparsing-3.2.0-py3-none-any.whl", hash = "sha256:93d9577b88da0bbea8cc8334ee8b918ed014968fd2ec383e868fb8afb1ccef84"}, + {file = "pyparsing-3.2.0.tar.gz", hash = "sha256:cbf74e27246d595d9a74b186b810f6fbb86726dbf3b9532efb343f6d7294fe9c"}, +] + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] + [[package]] name = "pytest" version = "8.3.4" @@ -1249,6 +1489,20 @@ files = [ {file = "RPi.GPIO-0.7.1.tar.gz", hash = "sha256:cd61c4b03c37b62bba4a5acfea9862749c33c618e0295e7e90aa4713fb373b70"}, ] +[[package]] +name = "rsa" +version = "4.9" +description = "Pure-Python RSA implementation" +optional = false +python-versions = ">=3.6,<4" +files = [ + {file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"}, + {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"}, +] + +[package.dependencies] +pyasn1 = ">=0.1.3" + [[package]] name = "shellingham" version = "1.5.4" @@ -1299,6 +1553,17 @@ files = [ {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] +[[package]] +name = "uritemplate" +version = "4.1.1" +description = "Implementation of RFC 6570 URI Templates" +optional = false +python-versions = ">=3.6" +files = [ + {file = "uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e"}, + {file = "uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0"}, +] + [[package]] name = "urllib3" version = "1.26.20" @@ -1377,4 +1642,4 @@ xmlsec = ["xmlsec (>=0.6.1)"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "489e03433932e2394cbd461898f0bae7b34df600108a3bbfdb161209b243fb94" +content-hash = "e5e140c3087e20e734f6c8f610d6ef851350340c05d63abced2d0e34b75b65b4" diff --git a/pyproject.toml b/pyproject.toml index 0c6b096..40d4908 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,8 @@ opencv-python = "4.9.0.80" flask = "^3.1.0" numpy = "1.26.4" rpi-gpio = { version = "^0.7.1", markers = "sys_platform == 'linux' and (platform_machine == 'armv7l' or platform_machine == 'aarch64')" } +bcrypt = "^4.2.1" +google-api-python-client = "^2.156.0" [tool.poetry.group.dev.dependencies] pytest = "^8.1.1" diff --git a/weld/app.py b/weld/app.py index a2626e6..82107ca 100644 --- a/weld/app.py +++ b/weld/app.py @@ -1,3 +1,4 @@ +import bcrypt from flask import Flask, render_template, request, jsonify, redirect, url_for, current_app from weld import backend, config @@ -8,6 +9,8 @@ jig_lock_service = backend.JigLockService() printer_service = backend.PrinterService() weld_count_service = backend.WeldCountService() +shift_service = backend.ShiftService() +google_api_service = backend.GoogleAPIService() def create_default_context() -> dict: @@ -17,6 +20,7 @@ def create_default_context() -> dict: """ context = { + "DeviceID": config.device_id, "TotalJigStations": None, "LeftWelder": None, "RightWelder": None, @@ -28,11 +32,19 @@ def create_default_context() -> dict: "ActualPartNumber": None, "ActualLeftWelds": None, "ActualRightWelds": None, + "WeldStats": None, "Error": None, } return context +@app.route("/api/parts", methods=["GET"]) +def get_parts(): + """Endpoint to fetch part numbers from the database.""" + database = google_api_service.get_updated_part_number_database() + return jsonify(database) + + @app.route("/api/lock-status", methods=["GET"]) def get_lock_status(): """Endpoint to check the lock status.""" @@ -40,12 +52,28 @@ def get_lock_status(): return jsonify(jig_lock_service.lock_status_json) +@app.route("/api/password-required", methods=["GET"]) +def is_password_required(): + """Endpoint to check if the password is required.""" + + if config.supervisor_password is None or config.supervisor_password == "": + return jsonify({"password_required": False}) + + return jsonify({"password_required": True}) + + @app.route("/api/lock-status", methods=["POST"]) def set_lock_status(): """Endpoint to update the lock status.""" data = request.get_json() + # Check if the password is correct + if config.supervisor_password is not None and config.supervisor_password != "": + if "password" not in data or not bcrypt.checkpw(password=data["password"].encode(), hashed_password=config.supervisor_password.encode()): + app.logger.warning("Incorrect password attempt") + return jsonify({"error": "Invalid password"}), 403 + if "is_locked" in data: if data["is_locked"]: app.logger.info("Locking the device") @@ -103,6 +131,10 @@ def part(): "ShiftNumber": shift_number, } ) + + # Start the shift + shift_service.start_shift(left_welder_name=left_welder, right_welder_name=right_welder, jig_number=jig_number, shift_number=shift_number) + return render_template("part.html", **context) else: @@ -211,6 +243,9 @@ def print_tag(): actual_left_welds = request.form.get("actual_left_welds") actual_right_welds = request.form.get("actual_right_welds") + # Add the welds to the database + shift_service.update_stats(part_number=actual_part_number) + # Update the context with submitted data context.update( { @@ -224,6 +259,7 @@ def print_tag(): "ActualPartNumber": actual_part_number, "ActualLeftWelds": actual_left_welds, "ActualRightWelds": actual_right_welds, + "WeldStats": shift_service.get_stats(), } ) diff --git a/weld/backend.py b/weld/backend.py index 5a89af8..3df8ec2 100644 --- a/weld/backend.py +++ b/weld/backend.py @@ -1,11 +1,14 @@ +import json import socket import logging import datetime import threading from groundlight import Groundlight from framegrab import FrameGrabber +from googleapiclient.discovery import build +from google.oauth2.service_account import Credentials -from weld.config import app_config, camera_config +from weld.config import app_config, camera_config, database_config logger = logging.getLogger(__name__) @@ -208,7 +211,7 @@ def weld_count_thread(self, jig_number: int) -> None: except Exception as e: logger.error(f"Failed to grab frame: {e}", exc_info=True) grabber.release() - grabber = FrameGrabber.create_grabber_yaml(jig_camera_config) + grabber = FrameGrabber.create_grabber(jig_camera_config) continue logger.info("Frame grabbed") @@ -224,8 +227,8 @@ def weld_count_thread(self, jig_number: int) -> None: iq_left = self.gl.ask_async(detector=self.detector, image=left_frame) iq_right = self.gl.ask_async(detector=self.detector, image=right_frame) - iq_left = self.gl.wait_for_ml_result(image_query=iq_left, timeout_sec=30) - iq_right = self.gl.wait_for_ml_result(image_query=iq_right, timeout_sec=30) + iq_left = self.gl.wait_for_ml_result(image_query=iq_left, timeout_sec=5) + iq_right = self.gl.wait_for_ml_result(image_query=iq_right, timeout_sec=5) except Exception as e: logger.error(f"Failed to get ML result: {e}", exc_info=True) continue @@ -417,3 +420,128 @@ def print_tag( ) return self._send_to_printer(zpl) + + +class ShiftService: + """Service to manage the session data for a shift.""" + + def __init__(self): + self.part_stats: dict[str, int] = {} + self.left_welder_name = None + self.right_welder_name = None + self.jig_number = None + self.shift_number = None + + def start_shift(self, left_welder_name: str, right_welder_name: str, jig_number: int, shift_number: int): + """Start the shift with the given welder names. + + Args: + left_welder_name (str): Name of the left welder. + right_welder_name (str): Name of the right welder. + jig_number (int): Jig number. + shift_number (int): Shift number. + """ + + # Create a new session with the given welder names. If the previous session is the same, do nothing. + if ( + self.left_welder_name == left_welder_name + and self.right_welder_name == right_welder_name + and self.jig_number == jig_number + and self.shift_number == shift_number + ): + logger.info("Shift already started with the same welders. Ignoring the request.") + return + + self.left_welder_name = left_welder_name + self.right_welder_name = right_welder_name + self.jig_number = jig_number + self.shift_number = shift_number + self.part_stats = {} + + def update_stats(self, part_number: str): + """Update the part stats for the given part number. + + Args: + part_number (str): Part number to update the stats. + """ + + if part_number not in self.part_stats: + self.part_stats[part_number] = 0 + self.part_stats[part_number] += 1 + + def get_stats(self): + """Get the current part stats. + + Returns: + dict: Part stats dictionary with part number as key and count as value. + """ + + return self.part_stats + + +class GoogleAPIService: + """Service to interact with the Google API.""" + + def __init__(self) -> None: + self.scopes = ["https://www.googleapis.com/auth/spreadsheets.readonly"] + self.service_account = database_config.service_account + self.spreadsheet_id = database_config.database_id + self.range_name = database_config.database_range + self.enabled = database_config.enabled + self.sheet = None + self.part_number_database = {} + + if self.enabled: + self.check_and_initialize_credentials() + + def check_and_initialize_credentials(self) -> bool: + """Check if the Google API settings are valid. If valid, initialize the credentials. + + Returns: + bool: True if the settings are valid, False otherwise. + """ + + try: + credentials = Credentials.from_service_account_info(self.service_account, scopes=self.scopes) + service = build("sheets", "v4", credentials=credentials) + + self.sheet = service.spreadsheets() + + return True + except Exception as e: + logger.error(f"Failed to initialize Google API credentials: {e}", exc_info=True) + + return False + + def get_updated_part_number_database(self) -> dict: + """Get the updated part number database from the Google Spreadsheet. + + Returns: + dict: Part number database dictionary with part number as key and weld counts as values. + """ + + if not self.enabled: + logger.info("Part Number Database is not enabled. Skipping update.") + return self.part_number_database + + try: + result = self.sheet.values().get(spreadsheetId=self.spreadsheet_id, range=self.range_name).execute() + values = result.get("values", []) + + self.part_number_database = {} + + for row in values: + if len(row) >= 1: # Ensure the first column (Part Number) is present + part_number = row[0] + + left_weld_count = int(row[1]) if len(row) > 1 and row[1].isdigit() else 0 + right_weld_count = int(row[2]) if len(row) > 2 and row[2].isdigit() else 0 + + self.part_number_database[part_number] = {"Left Weld Count": left_weld_count, "Right Weld Count": right_weld_count} + + return self.part_number_database + + except Exception as e: + logger.error(f"Failed to update Part Number Database: {e}", exc_info=True) + + return self.part_number_database diff --git a/weld/config.py b/weld/config.py index 3bd2922..fd067c6 100644 --- a/weld/config.py +++ b/weld/config.py @@ -62,12 +62,40 @@ } """ +SAMPLE_APP_DATABASE_CONFIG = """ +{ + "enabled": true, + "service_account - MODIFY THIS ENTIRE SECTION WITH JSON DOWNLOADED FROM GOOGLE CLOUD": { + "type": "service_account", + "project_id": "PROJECT_ID_HERE", + "private_key_id": "PRIVATE_KEY_ID_HERE", + "private_key": "PRIVATE_KEY_HERE", + "client_email": "CLIENT_EMAIL_HERE", + "client_id": "CLIENT_ID_HERE", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "CLIENT_CERT_URL_HERE", + "universe_domain": "googleapis.com" + }, + "database_id": "YOUR_DATABASE_ID_HERE", + "database_range": "Sheet1!A2:C" +} +""" + +device_id = os.getenv("WELD_APP_DEVICE_ID", "Default") + app_config_raw = os.getenv("WELD_APP_CONFIG", None) app_config = None camera_config_raw = os.getenv("WELD_APP_CAMERA_CONFIG", None) camera_config = None +database_config_raw = os.getenv("WELD_APP_DATABASE_CONFIG", None) +database_config = None + +supervisor_password = os.getenv("WELD_APP_SUPERVISOR_PASSWORD", None) + class PrinterConfig(BaseModel): printer_ip: str = Field(..., description="Tag Printer IP") @@ -92,10 +120,18 @@ class AppCameraConfig(BaseModel): jig_stations: dict[int, JigStationConfig] = Field(..., description="Mapping of jig stations to their raw camera configuration strings") +class DatabaseConfig(BaseModel): + enabled: bool = Field(False, description="Enable Part Number Database (Default: False)") + service_account: dict = Field(..., description="Service Account for Part Number Database") + database_id: str = Field(..., description="Database ID for Part Number Database (Google Spreadsheet ID)") + database_range: str = Field(..., description="Database Range for Part Number Database (Google Spreadsheet Range)") + + # Load configuration try: app_config = AppConfig.model_validate_json(app_config_raw) camera_config = AppCameraConfig.model_validate_json(camera_config_raw) + database_config = DatabaseConfig.model_validate_json(database_config_raw) logger.info("Configuration loaded successfully!") except Exception as e: logger.error(f"Error loading configuration: {e}", exc_info=True) diff --git a/weld/static/scripts/jig-lock.js b/weld/static/scripts/jig-lock.js index abc46ac..7d89e7f 100644 --- a/weld/static/scripts/jig-lock.js +++ b/weld/static/scripts/jig-lock.js @@ -3,11 +3,20 @@ document.addEventListener('DOMContentLoaded', function () { const toggleLock = document.getElementById("toggle-lock"); const iconUnlockPath = document.querySelector("#icon-unlock path"); const iconLockedPath = document.querySelector("#icon-lock path"); - var url = document.getElementById('index-form').action - + const passwordModal = document.getElementById("password-modal"); + const passwordInput = document.getElementById("password-input"); + const submitPasswordButton = document.getElementById("submit-password"); + const closeModal = document.getElementById("close-modal"); + const passwordError = document.getElementById("password-error"); + const url = document.getElementById('index-form').action; + + let isLocked = false; + let passwordRequired = false; + // Function to update the UI based on the lock status - function updateLockUI(isLocked) { - if (isLocked) { + function updateLockUI(lockStatus) { + isLocked = lockStatus; + if (lockStatus) { iconLockedPath.setAttribute("fill", "#1976D2"); iconUnlockPath.setAttribute("fill", "#808080"); toggleLock.checked = true; @@ -20,6 +29,16 @@ document.addEventListener('DOMContentLoaded', function () { } } + // Check if a password is required + function checkPasswordRequired() { + fetch(url + "api/password-required") + .then((response) => response.json()) + .then((data) => { + passwordRequired = data.password_required; + }) + .catch((error) => console.error("Error checking password requirement:", error)); + } + // Poll the /api/lock-status endpoint every 5 seconds function pollLockStatus() { fetch(url + "api/lock-status") @@ -30,42 +49,69 @@ document.addEventListener('DOMContentLoaded', function () { .catch((error) => console.error("Error fetching lock status:", error)); } - // Send a request to update the lock status - toggleLock.addEventListener("change", function () { - const isLocked = toggleLock.checked; + // Handle lock toggle click + toggleLock.addEventListener("change", function (event) { + if (passwordRequired) { + // Prevent the toggle from switching immediately + event.preventDefault(); + event.stopPropagation(); + passwordModal.style.display = "flex"; + // Focus on the password input + passwordInput.focus(); + } else { + updateLockStatus(!isLocked); + } + }); + + // Handle password submission + submitPasswordButton.addEventListener("click", function () { + const password = passwordInput.value; + updateLockStatus(!isLocked, password); + }); + + // Update lock status + function updateLockStatus(lockState, password = null) { + const requestData = { is_locked: lockState }; + if (password) { + requestData.password = password; + } + fetch(url + "api/lock-status", { method: "POST", headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ is_locked: isLocked }), + body: JSON.stringify(requestData), }) .then((response) => { if (!response.ok) { - throw new Error("Failed to update lock status"); + throw new Error("Incorrect password"); } return response.json(); }) .then((data) => { - if (isLocked) { - iconLockedPath.setAttribute("fill", "#1976D2"); - iconUnlockPath.setAttribute("fill", "#808080"); - toggleLock.checked = true; - console.log("Jig Lock Status: Locked"); - } else { - iconUnlockPath.setAttribute("fill", "#1976D2"); - iconLockedPath.setAttribute("fill", "#808080"); - toggleLock.checked = false; - console.log("Jig Lock Status: Unlocked"); + updateLockUI(data.is_locked); + if (passwordModal.style.display === "flex") { + passwordModal.style.display = "none"; } - console.log(data.message); + passwordError.style.display = "none"; + passwordInput.value = ""; }) .catch((error) => { console.error("Error updating lock status:", error); + if (password) { + passwordError.style.display = "block"; + } }); + } + + // Close modal + closeModal.addEventListener("click", function () { + passwordModal.style.display = "none"; }); - // Start polling for lock status + // Initialize password check and lock status polling + checkPasswordRequired(); pollLockStatus(); setInterval(pollLockStatus, 5000); -}); \ No newline at end of file +}); diff --git a/weld/static/scripts/print.js b/weld/static/scripts/print.js index 8a37606..8fd3718 100644 --- a/weld/static/scripts/print.js +++ b/weld/static/scripts/print.js @@ -1,11 +1,17 @@ document.addEventListener('DOMContentLoaded', () => { + const passwordModal = document.getElementById("password-modal"); document.addEventListener('keydown', function (event) { if (event.key === 'Enter') { - event.preventDefault(); // Prevent default form behavior - const submitButton = document.querySelector('#restart-button'); - if (submitButton) { - submitButton.click(); + event.preventDefault(); + // Prevent default behavior if the password modal is active + if (passwordModal && passwordModal.style.display === "flex") { + document.getElementById("submit-password").click(); // Trigger the password modal submission + } else { + const submitButton = document.querySelector('#restart-button'); + if (submitButton) { + submitButton.click(); + } } } }); diff --git a/weld/static/scripts/process.js b/weld/static/scripts/process.js index ad0cada..38e6191 100644 --- a/weld/static/scripts/process.js +++ b/weld/static/scripts/process.js @@ -1,4 +1,5 @@ document.addEventListener('DOMContentLoaded', () => { + const passwordModal = document.getElementById("password-modal"); var url = document.getElementById('index-form').action async function updateData() { @@ -25,10 +26,15 @@ document.addEventListener('DOMContentLoaded', () => { document.addEventListener('keydown', function (event) { if (event.key === 'Enter') { - event.preventDefault(); // Prevent default form behavior - const submitButton = document.querySelector('#review-button'); - if (submitButton) { - submitButton.click(); + event.preventDefault(); + // Prevent default behavior if the password modal is active + if (passwordModal && passwordModal.style.display === "flex") { + document.getElementById("submit-password").click(); // Trigger the password modal submission + } else { + const submitButton = document.querySelector('#review-button'); + if (submitButton) { + submitButton.click(); + } } } }); diff --git a/weld/static/scripts/review.js b/weld/static/scripts/review.js index 29df140..63e0470 100644 --- a/weld/static/scripts/review.js +++ b/weld/static/scripts/review.js @@ -7,6 +7,8 @@ document.addEventListener('DOMContentLoaded', () => { const expectedLeftWeld = document.getElementById('expected-left-welds'); const expectedRightWeld = document.getElementById('expected-right-welds'); const expectedTotalWeld = document.getElementById('expected-total-welds'); + const passwordModal = document.getElementById("password-modal"); + expectedTotalWeld.textContent = parseInt(expectedLeftWeld.textContent, 10) + parseInt(expectedRightWeld.textContent, 10); totalWeldDisplay.textContent = parseInt(leftWeldInput.value, 10) + parseInt(rightWeldInput.value, 10); @@ -63,10 +65,15 @@ document.addEventListener('DOMContentLoaded', () => { document.addEventListener('keydown', function (event) { if (event.key === 'Enter') { - event.preventDefault(); // Prevent default form behavior - const submitButton = document.querySelector('#print-tag-button'); - if (submitButton) { - submitButton.click(); + event.preventDefault(); + // Prevent default behavior if the password modal is active + if (passwordModal && passwordModal.style.display === "flex") { + document.getElementById("submit-password").click(); // Trigger the password modal submission + } else { + const submitButton = document.querySelector('#print-tag-button'); + if (submitButton) { + submitButton.click(); + } } } }); diff --git a/weld/static/scripts/start-shift.js b/weld/static/scripts/start-shift.js index 0a90b60..f9ad98d 100644 --- a/weld/static/scripts/start-shift.js +++ b/weld/static/scripts/start-shift.js @@ -4,6 +4,7 @@ document.addEventListener("DOMContentLoaded", function () { const shiftNumber = document.getElementById("shift-number"); const leftWelder = document.getElementById("left-welder"); const rightWelder = document.getElementById("right-welder"); + const passwordModal = document.getElementById("password-modal"); const errors = { jigNumber: document.getElementById("error-jig-number"), @@ -49,10 +50,15 @@ document.addEventListener("DOMContentLoaded", function () { document.addEventListener('keydown', function (event) { if (event.key === 'Enter') { - event.preventDefault(); // Prevent default form behavior - const submitButton = document.querySelector('#start-shift-button'); - if (submitButton) { - submitButton.click(); + event.preventDefault(); + // Prevent default behavior if the password modal is active + if (passwordModal && passwordModal.style.display === "flex") { + document.getElementById("submit-password").click(); // Trigger the password modal submission + } else { + const submitButton = document.querySelector('#start-shift-button'); + if (submitButton) { + submitButton.click(); + } } } }); diff --git a/weld/static/scripts/start-welding.js b/weld/static/scripts/start-welding.js index 47a3a47..7bfa2df 100644 --- a/weld/static/scripts/start-welding.js +++ b/weld/static/scripts/start-welding.js @@ -1,12 +1,44 @@ document.addEventListener('DOMContentLoaded', () => { - const startWeldingButton = document.getElementById("start-welding-button"); - const partNumber = document.getElementById("part-number"); + const manualPartNumberInput = document.getElementById('part-number'); + const manualCheckbox = document.getElementById('manual-checkbox'); + const manualPartNumberGroup = document.getElementById('manual-part-number-group'); + const dropdownPartNumberGroup = document.getElementById('dropdown-part-number-group'); + const partNumberSelect = document.getElementById('part-number-select'); + const refreshPartsButton = document.getElementById('refresh-parts'); const leftWeldInput = document.getElementById('expected-left-welds'); const rightWeldInput = document.getElementById('expected-right-welds'); const totalWeldDisplay = document.getElementById('total-welds'); + var url_prefix = document.getElementById('index-form').action - const errors = { - partNumber: document.getElementById("error-part-number"), + const toggleManualEntry = () => { + if (manualCheckbox.checked) { + manualPartNumberInput.name = 'part_number'; + partNumberSelect.removeAttribute('name'); + manualPartNumberGroup.classList.remove('hidden'); + dropdownPartNumberGroup.classList.add('hidden'); + } else { + partNumberSelect.name = 'part_number'; + manualPartNumberInput.removeAttribute('name'); + manualPartNumberGroup.classList.add('hidden'); + dropdownPartNumberGroup.classList.remove('hidden'); + } + }; + + const fetchParts = () => { + fetch(url_prefix + '/api/parts') + .then(response => response.json()) + .then(data => { + partNumberSelect.innerHTML = ''; + Object.keys(data).forEach(partNumber => { + const option = document.createElement('option'); + option.value = partNumber; + option.textContent = partNumber; + option.dataset.leftWelds = data[partNumber]['Left Weld Count']; + option.dataset.rightWelds = data[partNumber]['Right Weld Count']; + partNumberSelect.appendChild(option); + }); + }) + .catch(error => console.error('Error fetching part numbers:', error)); }; const updateTotalWelds = () => { @@ -15,22 +47,16 @@ document.addEventListener('DOMContentLoaded', () => { totalWeldDisplay.textContent = leftWelds + rightWelds; }; - function validateFields() { - let isValid = true; - - // Reset error messages - Object.values(errors).forEach((error) => { - error.textContent = ""; - }); - - // Check if Part Number has value - if (!partNumber.value) { - errors.partNumber.textContent = "Please enter a Part Number."; - isValid = false; + partNumberSelect.addEventListener('change', (event) => { + const selectedOption = event.target.selectedOptions[0]; + if (selectedOption) { + leftWeldInput.value = selectedOption.dataset.leftWelds || 0; + rightWeldInput.value = selectedOption.dataset.rightWelds || 0; + updateTotalWelds(); } + }); - return isValid; - } + refreshPartsButton.addEventListener('click', fetchParts); document.querySelectorAll('.increment, .decrement').forEach(button => { button.addEventListener('click', (event) => { @@ -45,28 +71,16 @@ document.addEventListener('DOMContentLoaded', () => { input.value = currentValue; updateTotalWelds(); - // Prevent form submission as we are not submitting a form event.preventDefault(); }); }); - document.addEventListener('keydown', function (event) { - if (event.key === 'Enter') { - event.preventDefault(); // Prevent default form behavior - const submitButton = document.querySelector('#start-welding-button'); - if (submitButton) { - submitButton.click(); - } - } - }); - - startWeldingButton.addEventListener("click", function (event) { - // Prevent form submission if validation fails - if (!validateFields()) { - event.preventDefault(); - } - }); - + manualCheckbox.addEventListener('change', toggleManualEntry); leftWeldInput.addEventListener('input', updateTotalWelds); rightWeldInput.addEventListener('input', updateTotalWelds); -}); \ No newline at end of file + + // Initial setup + toggleManualEntry(); + fetchParts(); + updateTotalWelds(); +}); diff --git a/weld/static/styles/styles.css b/weld/static/styles/styles.css index 7e4e271..97752b3 100644 --- a/weld/static/styles/styles.css +++ b/weld/static/styles/styles.css @@ -54,7 +54,6 @@ body { .footer { display: flex; justify-content: space-between; - align-items: center; position: fixed; bottom: 0; width: 100%; @@ -68,6 +67,7 @@ body { display: flex; justify-content: flex-start; align-items: center; + width: 25%; margin-left: 3%; } @@ -76,22 +76,25 @@ body { justify-content: center; align-items: center; gap: 10px; -} - -#lock-text { - width: 100px; - font-size: 16px; - color: #49494D; - text-align: center; + width: 50%; + margin: 0 auto; } #footer-right { display: flex; justify-content: flex-end; align-items: center; + width: 25%; margin-right: 3%; } +#lock-text { + width: 100px; + font-size: 16px; + color: #49494D; + text-align: center; +} + /* Content Page */ .content { margin: 40px 6%; @@ -140,6 +143,40 @@ body { font-size: 30px; } +.dropdown-refresh-group { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.dropdown { + flex: 1; + box-sizing: border-box; + width: 100%; + margin-top: 5px; + padding: 10px; + border: 1px solid #ccc; + border-radius: 5px; + font-size: 30px; +} + +.refresh-button { + display: inline-flex; + justify-content: center; + align-items: center; + padding: 0.5rem; + transition: background-color 0.3s; + background-color: #f0f0f0; + cursor: pointer; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 1rem; +} + +.refresh-button:hover { + background-color: #e0e0e0; +} + .text-box-weld { box-sizing: border-box; width: 100px; @@ -248,6 +285,7 @@ label { .start-button { position: relative; + min-width: 300px; margin: auto; padding: 30px 60px; background-color: #0450BA; @@ -402,4 +440,61 @@ input:checked+.slider:before { } } -/* Submission Form */ \ No newline at end of file +/* Password Popup */ +.modal { + display: flex; + justify-content: center; + align-items: center; + position: fixed; + z-index: 999; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); +} + +.modal-content { + width: 500px; + padding: 20px; + background-color: #fff; + border-radius: 5px; + text-align: center; +} + +/* Check Box */ +#manual-checkbox { + transform: scale(1.5); + margin-right: 0.5rem; + cursor: pointer; +} + +/* Table Cells */ +.weld-stats { + border-collapse: collapse; + width: 70%; + margin: 20px 0; + font-size: 16px; + text-align: center; +} + +.weld-stats th, +.weld-stats td { + padding: 8px; + border: 1px solid #ddd; + text-align: center; + vertical-align: middle; +} + +.weld-stats th { + background-color: #f4f4f4; + font-weight: bold; +} + +.weld-stats tbody tr:hover { + background-color: #f9f9f9; +} + +.hidden { + display: none; +} \ No newline at end of file diff --git a/weld/templates/footer.html b/weld/templates/footer.html index 084b41a..3b57048 100644 --- a/weld/templates/footer.html +++ b/weld/templates/footer.html @@ -1,6 +1,5 @@
+ + + \ No newline at end of file diff --git a/weld/templates/header.html b/weld/templates/header.html index f7cd294..d22fb23 100644 --- a/weld/templates/header.html +++ b/weld/templates/header.html @@ -4,6 +4,10 @@ GroundlightShift: {{ ShiftNumber }}
- -Part Number | +Completed | +
---|---|
{{ part_number }} | +{{ completed }} | +
No weld stats available.
+ {% endif %} +