diff --git a/Procfile b/Procfile index 9b81d80a4dca..272396dab9ad 100644 --- a/Procfile +++ b/Procfile @@ -1,5 +1,5 @@ # Web process: gunicorn -web: env/bin/gunicorn --chdir $APP_HOME/src/backend/InvenTree -c src/backend/InvenTree/gunicorn.conf.py InvenTree.wsgi -b 0.0.0.0:$PORT +web: env/bin/gunicorn --chdir $APP_HOME/src/backend/InvenTree -c src/backend/InvenTree/gunicorn.conf.py InvenTree.asgi -b 0.0.0.0:$PORT -k uvicorn_worker.UvicornWorker # Worker process: qcluster worker: env/bin/python src/backend/InvenTree/manage.py qcluster # Invoke commands diff --git a/contrib/container/Dockerfile b/contrib/container/Dockerfile index 27a452f8586d..37b4470ad549 100644 --- a/contrib/container/Dockerfile +++ b/contrib/container/Dockerfile @@ -139,7 +139,7 @@ COPY src/backend/requirements.txt ${INVENTREE_BACKEND_DIR}/requirements.txt COPY --from=frontend ${INVENTREE_BACKEND_DIR}/InvenTree/web/static/web ${INVENTREE_BACKEND_DIR}/InvenTree/web/static/web # Launch the production server -CMD gunicorn -c ./gunicorn.conf.py InvenTree.wsgi -b 0.0.0.0:8000 --chdir ${INVENTREE_BACKEND_DIR}/InvenTree +CMD gunicorn -c ./gunicorn.conf.py InvenTree.asgi -b 0.0.0.0:8000 --chdir ${INVENTREE_BACKEND_DIR}/InvenTree -k uvicorn_worker.UvicornWorker FROM inventree_base AS dev diff --git a/contrib/container/requirements.in b/contrib/container/requirements.in index 1a87893d54ba..66924e841b2d 100644 --- a/contrib/container/requirements.in +++ b/contrib/container/requirements.in @@ -13,6 +13,7 @@ mariadb>=1.1.8 # gunicorn web server gunicorn>=22.0.0 +uvicorn-worker # ASGI worker for gunicorn # LDAP required packages django-auth-ldap # Django integration for ldap auth diff --git a/contrib/container/requirements.txt b/contrib/container/requirements.txt index aa209c88ba2a..b609825321e8 100644 --- a/contrib/container/requirements.txt +++ b/contrib/container/requirements.txt @@ -4,6 +4,10 @@ asgiref==3.8.1 \ --hash=sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47 \ --hash=sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590 # via django +click==8.1.7 \ + --hash=sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28 \ + --hash=sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de + # via uvicorn django==4.2.15 \ --hash=sha256:61ee4a130efb8c451ef3467c67ca99fdce400fedd768634efc86a68c18d80d30 \ --hash=sha256:c77f926b81129493961e19c0e02188f8d07c112a1162df69bfab178ae447f94a @@ -17,7 +21,13 @@ django-auth-ldap==4.8.0 \ gunicorn==23.0.0 \ --hash=sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d \ --hash=sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec - # via -r contrib/container/requirements.in + # via + # -r contrib/container/requirements.in + # uvicorn-worker +h11==0.14.0 \ + --hash=sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d \ + --hash=sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761 + # via uvicorn invoke==2.2.0 \ --hash=sha256:6ea924cc53d4f78e3d98bc436b08069a03077e6f85ad1ddaa8a116d7dad15820 \ --hash=sha256:ee6cbb101af1a859c7fe84f2a264c059020b0cb7fe3535f9424300ab568f6bd5 @@ -219,6 +229,14 @@ uv==0.3.0 \ --hash=sha256:d3da56b87ec5aa4f2ae572127c754655bad3820dd41a4d37ed4d5e2f67035990 \ --hash=sha256:d87ff76da5128036c05db0291db7510a85cb8efb86538e8f49adc8074bb292f0 # via -r contrib/container/requirements.in +uvicorn==0.30.6 \ + --hash=sha256:4b15decdda1e72be08209e860a1e10e92439ad5b97cf44cc945fcbee66fc5788 \ + --hash=sha256:65fd46fe3fda5bdc1b03b94eb634923ff18cd35b2f084813ea79d1f103f711b5 + # via uvicorn-worker +uvicorn-worker==0.2.0 \ + --hash=sha256:65dcef25ab80a62e0919640f9582216ee05b3bb1dc2f0e58b354ca0511c398fb \ + --hash=sha256:f6894544391796be6eeed37d48cae9d7739e5a105f7e37061eccef2eac5a0295 + # via -r contrib/container/requirements.in wheel==0.44.0 \ --hash=sha256:2376a90c98cc337d18623527a97c31797bd02bad0033d41547043a1cbfbe448f \ --hash=sha256:a29c3f2817e95ab89aa4660681ad547c0e9547f20e75b0562fe7723c9a2a9d49 diff --git a/contrib/deploy/supervisord.conf b/contrib/deploy/supervisord.conf index a480cba8041d..1198961e7660 100644 --- a/contrib/deploy/supervisord.conf +++ b/contrib/deploy/supervisord.conf @@ -24,7 +24,7 @@ port = 127.0.0.1:9001 [program:inventree-server] user=inventree directory=/home/inventree/src/InvenTree -command=/home/inventree/env/bin/gunicorn -c gunicorn.conf.py InvenTree.wsgi +command=/home/inventree/env/bin/gunicorn -c gunicorn.conf.py InvenTree.asgi -k uvicorn_worker.UvicornWorker startsecs=10 autostart=true autorestart=true diff --git a/src/backend/InvenTree/InvenTree/asgi.py b/src/backend/InvenTree/InvenTree/asgi.py new file mode 100644 index 000000000000..56cf43ea1303 --- /dev/null +++ b/src/backend/InvenTree/InvenTree/asgi.py @@ -0,0 +1,28 @@ +"""ASGI config for InvenTree project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +from channels.auth import AuthMiddlewareStack +from channels.routing import ProtocolTypeRouter, URLRouter +from channels.security.websocket import AllowedHostsOriginValidator + +os.environ.setdefault( + 'DJANGO_SETTINGS_MODULE', 'InvenTree.settings' +) # pragma: no cover + +from InvenTree import routing + +application = ProtocolTypeRouter({ + 'http': get_asgi_application(), + 'websocket': AllowedHostsOriginValidator( + AuthMiddlewareStack(URLRouter(routing.websocket_urlpatterns)) + ), +}) diff --git a/src/backend/InvenTree/InvenTree/routing.py b/src/backend/InvenTree/InvenTree/routing.py new file mode 100644 index 000000000000..a99db6cee1e0 --- /dev/null +++ b/src/backend/InvenTree/InvenTree/routing.py @@ -0,0 +1,11 @@ +"""Async routings for InvenTree.""" + +from django.urls import path + +from channels.routing import URLRouter + +from web.consumers import InvenTreeConsumer + +websocket_urlpatterns = [ + path('ws/', URLRouter([path('page//', InvenTreeConsumer.as_asgi())])) +] diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index 8b59306e3c1c..f9813923cdbc 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -22,7 +22,12 @@ import pytz from dotenv import load_dotenv -from InvenTree.cache import get_cache_config, is_global_cache_enabled +from InvenTree.cache import ( + cache_host, + cache_port, + get_cache_config, + is_global_cache_enabled, +) from InvenTree.config import get_boolean_setting, get_custom_file, get_setting from InvenTree.ready import isInMainThread from InvenTree.sentry import default_sentry_dsn, init_sentry @@ -184,6 +189,7 @@ ) INSTALLED_APPS = [ + 'daphne', # ASGI enabled dev server # Admin site integration 'django.contrib.admin', # InvenTree apps @@ -239,6 +245,7 @@ 'dj_rest_auth.registration', # Registration APIs - dj-rest-auth' 'drf_spectacular', # API documentation 'django_ical', # For exporting calendars + 'channels', # websockets ] MIDDLEWARE = CONFIG.get( @@ -516,6 +523,7 @@ # WSGI default setting WSGI_APPLICATION = 'InvenTree.wsgi.application' +ASGI_APPLICATION = 'InvenTree.asgi.application' """ Configure the database backend based on the user-specified values. @@ -842,6 +850,17 @@ # as well Q_CLUSTER['django_redis'] = 'worker' +# Settings for channels +if GLOBAL_CACHE_ENABLED: # pragma: no cover + CHANNEL_LAYERS = { + 'default': { + 'BACKEND': 'channels_redis.core.RedisChannelLayer', + 'CONFIG': {'hosts': [cache_host(), cache_port()]}, + } + } +else: + CHANNEL_LAYERS = {'default': {'BACKEND': 'channels.layers.InMemoryChannelLayer'}} + # database user sessions SESSION_ENGINE = 'user_sessions.backends.db' LOGOUT_REDIRECT_URL = get_setting( diff --git a/src/backend/InvenTree/web/consumers.py b/src/backend/InvenTree/web/consumers.py new file mode 100644 index 000000000000..c0f1168d6be5 --- /dev/null +++ b/src/backend/InvenTree/web/consumers.py @@ -0,0 +1,82 @@ +"""Websocket consumers for web app.""" + +import json + +from asgiref.sync import async_to_sync +from channels.generic.websocket import JsonWebsocketConsumer + + +class InvenTreeConsumer(JsonWebsocketConsumer): + """This consumer is used to enable page attendance widgets.""" + + def __init__(self, *args, **kwargs): + """Set up context.""" + super().__init__(args, kwargs) + self.user = None + + def connect(self): + """Join a user to a page.""" + user = self.scope.get('user') + if not user or not user.is_authenticated: + self.close() + return + self.user = user + self.accept() + + from django.contrib.auth.models import User + + from InvenTree.serializers import UserSerializer + + self.room_name = self.scope['url_route']['kwargs']['url_name'] + self.room_group_name = f'chat_{self.room_name}' + # Join room group + async_to_sync(self.channel_layer.group_add)( + self.room_group_name, self.channel_name + ) + + # Announce all users + instances = User.objects.all() + self.send_json({ + 'type': 'users', + 'users': UserSerializer(instances, many=True).data, + }) + + def disconnect(self, code): + """Remove user from a page.""" + async_to_sync(self.channel_layer.group_discard)( + self.room_group_name, self.channel_name + ) + return super().disconnect(code) + + def receive_json(self, content, **kwargs): + """Handler for processing incoming messages.""" + message_type = content.get('type', None) + message = content.get('message', None) + + async_to_sync(self.channel_layer.group_send)( + self.room_group_name, + { + 'type': 'chat_message', + 'message': message, + 'user': self.user.get_username(), + }, + ) + + if message_type == '#TODO': + # TODO: implement this + ... + return super().receive_json(content, **kwargs) + + # Receive message from room group + def chat_message(self, event): + """Send message to WebSocket.""" + message = event['message'] + + # Send message to WebSocket + self.send( + text_data=json.dumps({ + 'type': 'chat_message', + 'message': message, + 'user': event['user'], + }) + ) diff --git a/src/backend/requirements-dev.txt b/src/backend/requirements-dev.txt index 751592ebb19a..fd080cc88f75 100644 --- a/src/backend/requirements-dev.txt +++ b/src/backend/requirements-dev.txt @@ -182,7 +182,9 @@ charset-normalizer==3.3.2 \ click==8.1.7 \ --hash=sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28 \ --hash=sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de - # via pip-tools + # via + # -c src/backend/requirements.txt + # pip-tools coverage[toml]==7.6.1 \ --hash=sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca \ --hash=sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d \ diff --git a/src/backend/requirements.in b/src/backend/requirements.in index 82bf2194f686..707160fd9c40 100644 --- a/src/backend/requirements.in +++ b/src/backend/requirements.in @@ -1,8 +1,10 @@ # Please keep this list sorted - if you pin a version provide a reason Django<5.0 # Django package +channels[daphne] # Websockers +channels_redis # Websockets with Redis coreapi # API documentation for djangorestframework cryptography>=40.0.0,!=40.0.2 # Core cryptographic functionality -django-allauth[openid,saml] # SSO for external providers via OpenID +django-allauth[openid,saml] # SSO for external providers via OpenID django-allauth-2fa # MFA / 2FA django-cleanup # Automated deletion of old / unused uploaded files django-cors-headers # CORS headers extension for DRF @@ -51,6 +53,7 @@ regex # Advanced regular expressions sentry-sdk # Error reporting (optional) setuptools # Standard dependency tablib[xls,xlsx,yaml] # Support for XLS and XLSX formats +uvicorn-worker # ASGI worker for gunicorn weasyprint # PDF generation whitenoise # Enhanced static file serving diff --git a/src/backend/requirements.txt b/src/backend/requirements.txt index 04a37c590b02..f2b6822d64d7 100644 --- a/src/backend/requirements.txt +++ b/src/backend/requirements.txt @@ -8,6 +8,9 @@ asgiref==3.8.1 \ --hash=sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47 \ --hash=sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590 # via + # channels + # channels-redis + # daphne # django # django-cors-headers async-timeout==4.0.3 \ @@ -20,6 +23,16 @@ attrs==24.2.0 \ # via # jsonschema # referencing + # service-identity + # twisted +autobahn==24.4.2 \ + --hash=sha256:a2d71ef1b0cf780b6d11f8b205fd2c7749765e65795f2ea7d823796642ee92c9 \ + --hash=sha256:c56a2abe7ac78abbfb778c02892d673a4de58fd004d088cd7ab297db25918e81 + # via daphne +automat==24.8.1 \ + --hash=sha256:b34227cf63f6325b8ad2399ede780675083e439b20c323d376373d8ee6306d88 \ + --hash=sha256:bf029a7bc3da1e2c24da2343e7598affaa9f10bf0ab63ff808566ce90551e02a + # via twisted babel==2.16.0 \ --hash=sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b \ --hash=sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316 @@ -190,6 +203,16 @@ cffi==1.17.0 \ # via # cryptography # weasyprint +channels[daphne]==4.1.0 \ + --hash=sha256:a3c4419307f582c3f71d67bfb6eff748ae819c2f360b9b141694d84f242baa48 \ + --hash=sha256:e0ed375719f5c1851861f05ed4ce78b0166f9245ca0ecd836cb77d4bb531489d + # via + # -r src/backend/requirements.in + # channels-redis +channels-redis==4.2.0 \ + --hash=sha256:01c26c4d5d3a203f104bba9e5585c0305a70df390d21792386586068162027fd \ + --hash=sha256:2c5b944a39bd984b72aa8005a3ae11637bf29b5092adeb91c9aad4ab819a8ac4 + # via -r src/backend/requirements.in charset-normalizer==3.3.2 \ --hash=sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027 \ --hash=sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087 \ @@ -282,6 +305,14 @@ charset-normalizer==3.3.2 \ --hash=sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519 \ --hash=sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561 # via requests +click==8.1.7 \ + --hash=sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28 \ + --hash=sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de + # via uvicorn +constantly==23.10.4 \ + --hash=sha256:3fd9b4d1c3dc1ec9757f3c52aef7e53ad9323dbe39f51dfd4c43853b68dfa3f9 \ + --hash=sha256:aa92b70a33e2ac0bb33cd745eb61776594dc48764b06c35e0efd050b7f1c7cbd + # via twisted coreapi==2.3.3 \ --hash=sha256:46145fcc1f7017c076a2ef684969b641d18a2991051fddec9458ad3f78ffc1cb \ --hash=sha256:bf39d118d6d3e171f10df9ede5666f63ad80bba9a29a8ec17726a66cf52ee6f3 @@ -320,11 +351,18 @@ cryptography==43.0.1 \ --hash=sha256:fba1007b3ef89946dbbb515aeeb41e30203b004f0b4b00e5e16078b518563289 # via # -r src/backend/requirements.in + # autobahn # djangorestframework-simplejwt + # pyopenssl + # service-identity cssselect2==0.7.0 \ --hash=sha256:1ccd984dab89fc68955043aca4e1b03e0cf29cad9880f6e28e3ba7a74b14aa5a \ --hash=sha256:fd23a65bfd444595913f02fc71f6b286c29261e354c41d722ca7a261a49b5969 # via weasyprint +daphne==4.1.2 \ + --hash=sha256:618d1322bb4d875342b99dd2a10da2d9aae7ee3645f765965fdc1e658ea5290a \ + --hash=sha256:fcbcace38eb86624ae247c7ffdc8ac12f155d7d19eafac4247381896d6f33761 + # via channels defusedxml==0.7.1 \ --hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 \ --hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61 @@ -351,6 +389,7 @@ django==4.2.15 \ --hash=sha256:c77f926b81129493961e19c0e02188f8d07c112a1162df69bfab178ae447f94a # via # -r src/backend/requirements.in + # channels # dj-rest-auth # django-allauth # django-allauth-2fa @@ -687,11 +726,23 @@ grpcio==1.65.5 \ gunicorn==23.0.0 \ --hash=sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d \ --hash=sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec - # via -r src/backend/requirements.in + # via + # -r src/backend/requirements.in + # uvicorn-worker +h11==0.14.0 \ + --hash=sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d \ + --hash=sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761 + # via uvicorn html5lib==1.1 \ --hash=sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d \ --hash=sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f # via weasyprint +hyperlink==21.0.0 \ + --hash=sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b \ + --hash=sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4 + # via + # autobahn + # twisted icalendar==5.0.13 \ --hash=sha256:5ded5415e2e1edef5ab230024a75878a7a81d518a3b1ae4f34bf20b173c84dc2 \ --hash=sha256:92799fde8cce0b61daa8383593836d1e19136e504fa1671f471f98be9b029706 @@ -699,7 +750,10 @@ icalendar==5.0.13 \ idna==3.7 \ --hash=sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc \ --hash=sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0 - # via requests + # via + # hyperlink + # requests + # twisted importlib-metadata==8.0.0 \ --hash=sha256:15584cf2b1bf449d98ff8a6ff1abef57bf20f3ac6454f431736cd3e660921b2f \ --hash=sha256:188bd24e4c346d3f0a933f275c2fec67050326a856b9a359881d7c2a697e8812 @@ -707,6 +761,10 @@ importlib-metadata==8.0.0 \ # django-q2 # markdown # opentelemetry-api +incremental==24.7.2 \ + --hash=sha256:8cb2c3431530bec48ad70513931a760f446ad6c25e8333ca5d95e24b0ed7b8fe \ + --hash=sha256:fb4f1d47ee60efe87d4f6f0ebb5f70b9760db2b2574c59c8e8912be4ebd464c9 + # via twisted inflection==0.5.1 \ --hash=sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417 \ --hash=sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2 @@ -942,6 +1000,64 @@ markupsafe==2.1.5 \ --hash=sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd \ --hash=sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68 # via jinja2 +msgpack==1.0.8 \ + --hash=sha256:00e073efcba9ea99db5acef3959efa45b52bc67b61b00823d2a1a6944bf45982 \ + --hash=sha256:0726c282d188e204281ebd8de31724b7d749adebc086873a59efb8cf7ae27df3 \ + --hash=sha256:0ceea77719d45c839fd73abcb190b8390412a890df2f83fb8cf49b2a4b5c2f40 \ + --hash=sha256:114be227f5213ef8b215c22dde19532f5da9652e56e8ce969bf0a26d7c419fee \ + --hash=sha256:13577ec9e247f8741c84d06b9ece5f654920d8365a4b636ce0e44f15e07ec693 \ + --hash=sha256:1876b0b653a808fcd50123b953af170c535027bf1d053b59790eebb0aeb38950 \ + --hash=sha256:1ab0bbcd4d1f7b6991ee7c753655b481c50084294218de69365f8f1970d4c151 \ + --hash=sha256:1cce488457370ffd1f953846f82323cb6b2ad2190987cd4d70b2713e17268d24 \ + --hash=sha256:26ee97a8261e6e35885c2ecd2fd4a6d38252246f94a2aec23665a4e66d066305 \ + --hash=sha256:3528807cbbb7f315bb81959d5961855e7ba52aa60a3097151cb21956fbc7502b \ + --hash=sha256:374a8e88ddab84b9ada695d255679fb99c53513c0a51778796fcf0944d6c789c \ + --hash=sha256:376081f471a2ef24828b83a641a02c575d6103a3ad7fd7dade5486cad10ea659 \ + --hash=sha256:3923a1778f7e5ef31865893fdca12a8d7dc03a44b33e2a5f3295416314c09f5d \ + --hash=sha256:4916727e31c28be8beaf11cf117d6f6f188dcc36daae4e851fee88646f5b6b18 \ + --hash=sha256:493c5c5e44b06d6c9268ce21b302c9ca055c1fd3484c25ba41d34476c76ee746 \ + --hash=sha256:505fe3d03856ac7d215dbe005414bc28505d26f0c128906037e66d98c4e95868 \ + --hash=sha256:5845fdf5e5d5b78a49b826fcdc0eb2e2aa7191980e3d2cfd2a30303a74f212e2 \ + --hash=sha256:5c330eace3dd100bdb54b5653b966de7f51c26ec4a7d4e87132d9b4f738220ba \ + --hash=sha256:5dbf059fb4b7c240c873c1245ee112505be27497e90f7c6591261c7d3c3a8228 \ + --hash=sha256:5e390971d082dba073c05dbd56322427d3280b7cc8b53484c9377adfbae67dc2 \ + --hash=sha256:5fbb160554e319f7b22ecf530a80a3ff496d38e8e07ae763b9e82fadfe96f273 \ + --hash=sha256:64d0fcd436c5683fdd7c907eeae5e2cbb5eb872fafbc03a43609d7941840995c \ + --hash=sha256:69284049d07fce531c17404fcba2bb1df472bc2dcdac642ae71a2d079d950653 \ + --hash=sha256:6a0e76621f6e1f908ae52860bdcb58e1ca85231a9b0545e64509c931dd34275a \ + --hash=sha256:73ee792784d48aa338bba28063e19a27e8d989344f34aad14ea6e1b9bd83f596 \ + --hash=sha256:74398a4cf19de42e1498368c36eed45d9528f5fd0155241e82c4082b7e16cffd \ + --hash=sha256:7938111ed1358f536daf311be244f34df7bf3cdedb3ed883787aca97778b28d8 \ + --hash=sha256:82d92c773fbc6942a7a8b520d22c11cfc8fd83bba86116bfcf962c2f5c2ecdaa \ + --hash=sha256:83b5c044f3eff2a6534768ccfd50425939e7a8b5cf9a7261c385de1e20dcfc85 \ + --hash=sha256:8db8e423192303ed77cff4dce3a4b88dbfaf43979d280181558af5e2c3c71afc \ + --hash=sha256:9517004e21664f2b5a5fd6333b0731b9cf0817403a941b393d89a2f1dc2bd836 \ + --hash=sha256:95c02b0e27e706e48d0e5426d1710ca78e0f0628d6e89d5b5a5b91a5f12274f3 \ + --hash=sha256:99881222f4a8c2f641f25703963a5cefb076adffd959e0558dc9f803a52d6a58 \ + --hash=sha256:9ee32dcb8e531adae1f1ca568822e9b3a738369b3b686d1477cbc643c4a9c128 \ + --hash=sha256:a22e47578b30a3e199ab067a4d43d790249b3c0587d9a771921f86250c8435db \ + --hash=sha256:b5505774ea2a73a86ea176e8a9a4a7c8bf5d521050f0f6f8426afe798689243f \ + --hash=sha256:bd739c9251d01e0279ce729e37b39d49a08c0420d3fee7f2a4968c0576678f77 \ + --hash=sha256:d16a786905034e7e34098634b184a7d81f91d4c3d246edc6bd7aefb2fd8ea6ad \ + --hash=sha256:d3420522057ebab1728b21ad473aa950026d07cb09da41103f8e597dfbfaeb13 \ + --hash=sha256:d56fd9f1f1cdc8227d7b7918f55091349741904d9520c65f0139a9755952c9e8 \ + --hash=sha256:d661dc4785affa9d0edfdd1e59ec056a58b3dbb9f196fa43587f3ddac654ac7b \ + --hash=sha256:dfe1f0f0ed5785c187144c46a292b8c34c1295c01da12e10ccddfc16def4448a \ + --hash=sha256:e1dd7839443592d00e96db831eddb4111a2a81a46b028f0facd60a09ebbdd543 \ + --hash=sha256:e2872993e209f7ed04d963e4b4fbae72d034844ec66bc4ca403329db2074377b \ + --hash=sha256:e2f879ab92ce502a1e65fce390eab619774dda6a6ff719718069ac94084098ce \ + --hash=sha256:e3aa7e51d738e0ec0afbed661261513b38b3014754c9459508399baf14ae0c9d \ + --hash=sha256:e532dbd6ddfe13946de050d7474e3f5fb6ec774fbb1a188aaf469b08cf04189a \ + --hash=sha256:e6b7842518a63a9f17107eb176320960ec095a8ee3b4420b5f688e24bf50c53c \ + --hash=sha256:e75753aeda0ddc4c28dce4c32ba2f6ec30b1b02f6c0b14e547841ba5b24f753f \ + --hash=sha256:eadb9f826c138e6cf3c49d6f8de88225a3c0ab181a9b4ba792e006e5292d150e \ + --hash=sha256:ed59dd52075f8fc91da6053b12e8c89e37aa043f8986efd89e61fae69dc1b011 \ + --hash=sha256:ef254a06bcea461e65ff0373d8a0dd1ed3aa004af48839f002a0c994a6f72d04 \ + --hash=sha256:f3709997b228685fe53e8c433e2df9f0cdb5f4542bd5114ed17ac3c0129b0480 \ + --hash=sha256:f51bab98d52739c50c56658cc303f190785f9a2cd97b823357e7aeae54c8f68a \ + --hash=sha256:f9904e24646570539a8950400602d66d2b2c492b9010ea7e965025cb71d0c86d \ + --hash=sha256:f9af38a89b6a5c04b7d18c492c8ccf2aee7048aff1ce8437c4683bb5a1df893d + # via channels-redis odfpy==1.4.1 \ --hash=sha256:db766a6e59c5103212f3cc92ec8dd50a0f3a02790233ed0b52148b70d3c438ec # via tablib @@ -1162,6 +1278,16 @@ py-moneyed==3.0 \ --hash=sha256:4906f0f02cf2b91edba2e156f2d4e9a78f224059ab8c8fa2ff26230c75d894e8 \ --hash=sha256:9583a14f99c05b46196193d8185206e9b73c8439fc8a5eee9cfc7e733676d9bb # via django-money +pyasn1==0.6.0 \ + --hash=sha256:3a35ab2c4b5ef98e17dfdec8ab074046fbda76e281c5a706ccd82328cfc8f64c \ + --hash=sha256:cca4bb0f2df5504f02f6f8a775b6e416ff9b0b3b16f7ee80b5a3153d9b804473 + # via + # pyasn1-modules + # service-identity +pyasn1-modules==0.4.0 \ + --hash=sha256:831dbcea1b177b28c9baddf4c6d1013c24c3accd14a1873fffaa6a2e905f17b6 \ + --hash=sha256:be04f15b66c206eed667e0bb5ab27e2b1855ea54a842e5037738099e8ca4ae0b + # via service-identity pycparser==2.22 \ --hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \ --hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc @@ -1176,6 +1302,10 @@ pyjwt==2.9.0 \ --hash=sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850 \ --hash=sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c # via djangorestframework-simplejwt +pyopenssl==24.2.1 \ + --hash=sha256:4247f0dbe3748d560dcbb2ff3ea01af0f9a1a001ef5f7c4c647956ed8cbf0e95 \ + --hash=sha256:967d5719b12b243588573f39b0c677637145c7a1ffedcd495a487e58177fbb8d + # via twisted pyphen==0.16.0 \ --hash=sha256:2c006b3ddf072c9571ab97606d9ab3c26a92eaced4c0d59fd1d26988f308f413 \ --hash=sha256:b4a4c6d7d5654b698b5fc68123148bb799b3debe0175d1d5dc3edfe93066fc4c @@ -1395,7 +1525,9 @@ rapidfuzz==3.9.6 \ redis==5.0.8 \ --hash=sha256:0c5b10d387568dfe0698c6fad6615750c24170e548ca2deac10c649d463e9870 \ --hash=sha256:56134ee08ea909106090934adc36f65c9bcbbaecea5b21ba704ba6fb561f8eb4 - # via django-redis + # via + # channels-redis + # django-redis referencing==0.35.1 \ --hash=sha256:25b42124a6c8b632a425174f24087783efb348a6f1e0008e63cd4466fedf703c \ --hash=sha256:eda6d3234d62814d1c64e305c1331c9a3a6132da475ab6382eaa997b21ee75de @@ -1602,13 +1734,20 @@ sentry-sdk==2.13.0 \ # via # -r src/backend/requirements.in # django-q-sentry +service-identity==24.1.0 \ + --hash=sha256:6829c9d62fb832c2e1c435629b0a8c476e1929881f28bee4d20bc24161009221 \ + --hash=sha256:a28caf8130c8a5c1c7a6f5293faaf239bbfb7751e4862436920ee6f2616f568a + # via twisted setuptools==74.1.1 \ --hash=sha256:2353af060c06388be1cecbf5953dcdb1f38362f87a2356c480b6b4d5fcfc8847 \ --hash=sha256:fc91b5f89e392ef5b77fe143b17e32f65d3024744fba66dc3afe07201684d766 # via # -r src/backend/requirements.in + # autobahn # django-money + # incremental # opentelemetry-instrumentation + # zope-interface sgmllib3k==1.0.0 \ --hash=sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9 # via feedparser @@ -1642,12 +1781,23 @@ tinycss2==1.2.1 \ tomli==2.0.1 \ --hash=sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc \ --hash=sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f - # via pip-licenses + # via + # incremental + # pip-licenses +twisted[tls]==24.7.0 \ + --hash=sha256:5a60147f044187a127ec7da96d170d49bcce50c6fd36f594e60f4587eff4d394 \ + --hash=sha256:734832ef98108136e222b5230075b1079dad8a3fc5637319615619a7725b0c81 + # via daphne +txaio==23.1.1 \ + --hash=sha256:aaea42f8aad50e0ecfb976130ada140797e9dcb85fad2cf72b0f37f8cefcb490 \ + --hash=sha256:f9a9216e976e5e3246dfd112ad7ad55ca915606b60b84a757ac769bd404ff704 + # via autobahn typing-extensions==4.12.2 \ --hash=sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d \ --hash=sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8 # via # asgiref + # automat # drf-spectacular # flexcache # flexparser @@ -1655,6 +1805,8 @@ typing-extensions==4.12.2 \ # pint # py-moneyed # qrcode + # twisted + # uvicorn uritemplate==4.1.1 \ --hash=sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0 \ --hash=sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e @@ -1668,6 +1820,14 @@ urllib3==2.2.2 \ # dulwich # requests # sentry-sdk +uvicorn==0.30.3 \ + --hash=sha256:0d114d0831ff1adbf231d358cbf42f17333413042552a624ea6a9b4c33dcfd81 \ + --hash=sha256:94a3608da0e530cea8f69683aa4126364ac18e3826b6630d1a65f4638aade503 + # via uvicorn-worker +uvicorn-worker==0.2.0 \ + --hash=sha256:65dcef25ab80a62e0919640f9582216ee05b3bb1dc2f0e58b354ca0511c398fb \ + --hash=sha256:f6894544391796be6eeed37d48cae9d7739e5a105f7e37061eccef2eac5a0295 + # via -r src/backend/requirements.in wcwidth==0.2.13 \ --hash=sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859 \ --hash=sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5 @@ -1837,6 +1997,44 @@ zipp==3.20.0 \ --hash=sha256:0145e43d89664cfe1a2e533adc75adafed82fe2da404b4bbb6b026c0157bdb31 \ --hash=sha256:58da6168be89f0be59beb194da1250516fdaa062ccebd30127ac65d30045e10d # via importlib-metadata +zope-interface==6.4.post2 \ + --hash=sha256:00b5c3e9744dcdc9e84c24ed6646d5cf0cf66551347b310b3ffd70f056535854 \ + --hash=sha256:0e4fa5d34d7973e6b0efa46fe4405090f3b406f64b6290facbb19dcbf642ad6b \ + --hash=sha256:136cacdde1a2c5e5bc3d0b2a1beed733f97e2dad8c2ad3c2e17116f6590a3827 \ + --hash=sha256:1730c93a38b5a18d24549bc81613223962a19d457cfda9bdc66e542f475a36f4 \ + --hash=sha256:1a62fd6cd518693568e23e02f41816adedfca637f26716837681c90b36af3671 \ + --hash=sha256:1c207e6f6dfd5749a26f5a5fd966602d6b824ec00d2df84a7e9a924e8933654e \ + --hash=sha256:2eccd5bef45883802848f821d940367c1d0ad588de71e5cabe3813175444202c \ + --hash=sha256:33ee982237cffaf946db365c3a6ebaa37855d8e3ca5800f6f48890209c1cfefc \ + --hash=sha256:3d136e5b8821073e1a09dde3eb076ea9988e7010c54ffe4d39701adf0c303438 \ + --hash=sha256:47654177e675bafdf4e4738ce58cdc5c6d6ee2157ac0a78a3fa460942b9d64a8 \ + --hash=sha256:47937cf2e7ed4e0e37f7851c76edeb8543ec9b0eae149b36ecd26176ff1ca874 \ + --hash=sha256:4ac46298e0143d91e4644a27a769d1388d5d89e82ee0cf37bf2b0b001b9712a4 \ + --hash=sha256:4c0b208a5d6c81434bdfa0f06d9b667e5de15af84d8cae5723c3a33ba6611b82 \ + --hash=sha256:551db2fe892fcbefb38f6f81ffa62de11090c8119fd4e66a60f3adff70751ec7 \ + --hash=sha256:599f3b07bde2627e163ce484d5497a54a0a8437779362395c6b25e68c6590ede \ + --hash=sha256:5ef8356f16b1a83609f7a992a6e33d792bb5eff2370712c9eaae0d02e1924341 \ + --hash=sha256:5fe919027f29b12f7a2562ba0daf3e045cb388f844e022552a5674fcdf5d21f1 \ + --hash=sha256:6f0a6be264afb094975b5ef55c911379d6989caa87c4e558814ec4f5125cfa2e \ + --hash=sha256:706efc19f9679a1b425d6fa2b4bc770d976d0984335eaea0869bd32f627591d2 \ + --hash=sha256:73f9752cf3596771c7726f7eea5b9e634ad47c6d863043589a1c3bb31325c7eb \ + --hash=sha256:762e616199f6319bb98e7f4f27d254c84c5fb1c25c908c2a9d0f92b92fb27530 \ + --hash=sha256:866a0f583be79f0def667a5d2c60b7b4cc68f0c0a470f227e1122691b443c934 \ + --hash=sha256:86a94af4a88110ed4bb8961f5ac72edf782958e665d5bfceaab6bf388420a78b \ + --hash=sha256:8e0343a6e06d94f6b6ac52fbc75269b41dd3c57066541a6c76517f69fe67cb43 \ + --hash=sha256:97e615eab34bd8477c3f34197a17ce08c648d38467489359cb9eb7394f1083f7 \ + --hash=sha256:a96e6d4074db29b152222c34d7eec2e2db2f92638d2b2b2c704f9e8db3ae0edc \ + --hash=sha256:b912750b13d76af8aac45ddf4679535def304b2a48a07989ec736508d0bbfbde \ + --hash=sha256:bc2676312cc3468a25aac001ec727168994ea3b69b48914944a44c6a0b251e79 \ + --hash=sha256:cebff2fe5dc82cb22122e4e1225e00a4a506b1a16fafa911142ee124febf2c9e \ + --hash=sha256:d22fce0b0f5715cdac082e35a9e735a1752dc8585f005d045abb1a7c20e197f9 \ + --hash=sha256:d3f7e001328bd6466b3414215f66dde3c7c13d8025a9c160a75d7b2687090d15 \ + --hash=sha256:d3fe667935e9562407c2511570dca14604a654988a13d8725667e95161d92e9b \ + --hash=sha256:dabb70a6e3d9c22df50e08dc55b14ca2a99da95a2d941954255ac76fd6982bc5 \ + --hash=sha256:e2fb8e8158306567a3a9a41670c1ff99d0567d7fc96fa93b7abf8b519a46b250 \ + --hash=sha256:e96ac6b3169940a8cd57b4f2b8edcad8f5213b60efcd197d59fbe52f0accd66e \ + --hash=sha256:fbf649bc77510ef2521cf797700b96167bb77838c40780da7ea3edd8b78044d1 + # via twisted zopfli==0.2.3 \ --hash=sha256:0574372283befa5af98fb31407e1fe6822f2f9c437ef69e7fa260e49022d8a65 \ --hash=sha256:082f030b2b7d6d4597ac517816e499c63b92130aa8f4f74a3788ebaa5770f974 \ diff --git a/src/frontend/package.json b/src/frontend/package.json index 5bb6796be900..40c6d256e466 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -61,6 +61,7 @@ "react-is": "^18.3.1", "react-router-dom": "^6.26.2", "react-select": "^5.8.0", + "react-use-websocket": "^4.8.1", "react-simplemde-editor": "^5.2.0", "react-window": "^1.8.10", "recharts": "^2.12.7", diff --git a/src/frontend/src/components/nav/Header.tsx b/src/frontend/src/components/nav/Header.tsx index 7ba9ef6f8b63..9a042cb8f58e 100644 --- a/src/frontend/src/components/nav/Header.tsx +++ b/src/frontend/src/components/nav/Header.tsx @@ -19,6 +19,7 @@ import { MainMenu } from './MainMenu'; import { NavHoverMenu } from './NavHoverMenu'; import { NavigationDrawer } from './NavigationDrawer'; import { NotificationDrawer } from './NotificationDrawer'; +import { PageAttendanceComponent } from './PageAttendanceComponent'; import { SearchDrawer } from './SearchDrawer'; export function Header() { @@ -103,6 +104,7 @@ export function Header() { + diff --git a/src/frontend/src/components/nav/PageAttendanceComponent.tsx b/src/frontend/src/components/nav/PageAttendanceComponent.tsx new file mode 100644 index 000000000000..698507ff143c --- /dev/null +++ b/src/frontend/src/components/nav/PageAttendanceComponent.tsx @@ -0,0 +1,192 @@ +import { Trans, t } from '@lingui/macro'; +import { + ActionIcon, + Button, + Divider, + Drawer, + Group, + Indicator, + List, + Pill, + Stack, + Text, + TextInput +} from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { IconEyeDotted, IconLink } from '@tabler/icons-react'; +import { useEffect, useMemo, useState } from 'react'; +import { useLocation } from 'react-router-dom'; +import useWebSocket, { ReadyState } from 'react-use-websocket'; + +import { useLocalState } from '../../states/LocalState'; +import { StylishText } from '../items/StylishText'; + +interface PageMessages { + type: 'chat_message' | 'users' | 'unknown'; + message: string; + data: any; + user: string; +} + +export const PageAttendanceComponent = () => { + const [host] = useLocalState((state) => [state.host]); + const location = useLocation(); + const [socketUrl, setSocketUrl] = useState(null); + + // websocket history + const [parsedMessageHistory, setParsedMessageHistory] = useState< + PageMessages[] + >([]); + const [ + isAttendanceDrawerOpened, + { open: openAttendanceDrawer, close: closeAttendanceDrawer } + ] = useDisclosure(false); + + const messageCount = useMemo(() => { + return parsedMessageHistory.filter( + (message) => message.type === 'chat_message' + ).length; + }, [parsedMessageHistory]); + + // Websockets + useEffect(() => { + setParsedMessageHistory([]); + let new_room_name = location.pathname.slice(1).replaceAll('/', '__'); + if (new_room_name === '') new_room_name = 'home'; + const cleaned_host = host.replace(/(^\w+:|^)\/\//, ''); + setSocketUrl(`ws://${cleaned_host}/ws/page/${new_room_name}/`); + }, [host, location.pathname]); + const { sendMessage, lastMessage, readyState } = useWebSocket(socketUrl); + + useEffect(() => { + if (lastMessage !== null) { + setParsedMessageHistory((prev) => + prev.concat(JSON.parse(lastMessage.data)) + ); + } + }, [lastMessage]); + + return ( + + + + + { + closeAttendanceDrawer(); + }} + sendMessage={sendMessage} + readyState={readyState} + messages={parsedMessageHistory} + /> + + ); +}; + +function AttendanceDrawer({ + opened, + onClose, + sendMessage, + readyState, + messages +}: Readonly<{ + opened: boolean; + onClose: () => void; + sendMessage: (message: string) => void; + readyState: ReadyState; + messages: PageMessages[]; +}>) { + const [inputValue, setInputValue] = useState(''); + function handleClickSendMessage() { + sendMessage(JSON.stringify({ message: inputValue })); + setInputValue(''); + } + const connectionStatus = { + [ReadyState.CONNECTING]: t`Connecting`, + [ReadyState.OPEN]: t`Open`, + [ReadyState.CLOSING]: t`Closing`, + [ReadyState.CLOSED]: t`Closed`, + [ReadyState.UNINSTANTIATED]: t`Uninstantiated` + }[readyState]; + const displayMessages = useMemo(() => { + return messages.filter((message) => message.type === 'chat_message'); + }, [messages]); + + return ( + + {t`Live Page Attendance`}{' '} + + + {connectionStatus} + + + } + > + + + + Shows users that have this page currently open. All chat is empheral + and only visible as long as a user is on the page. + + + + { + setInputValue(e.target.value); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + setTimeout(() => { + handleClickSendMessage(); + }, 200); + } + }} + /> + + }> + {displayMessages.map((message, idx) => ( + + + {message.user} + {message.message} + + + ))} + + + + ); +} diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index e3d101cff76d..33b4ef20fd5b 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -3805,6 +3805,10 @@ react-transition-group@4.4.5, react-transition-group@^4.3.0, react-transition-gr loose-envify "^1.4.0" prop-types "^15.6.2" +react-use-websocket@^4.8.1: + version "4.8.1" + resolved "https://registry.yarnpkg.com/react-use-websocket/-/react-use-websocket-4.8.1.tgz#be06a0bc956c6d56391a29cbe2caa6ae8edb5615" + integrity sha512-FTXuG5O+LFozmu1BRfrzl7UIQngECvGJmL7BHsK4TYXuVt+mCizVA8lT0hGSIF0Z0TedF7bOo1nRzOUdginhDw== react-window@^1.8.10: version "1.8.10" resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.10.tgz#9e6b08548316814b443f7002b1cf8fd3a1bdde03" diff --git a/tasks.py b/tasks.py index 0e69b733bed5..f6162b394d33 100644 --- a/tasks.py +++ b/tasks.py @@ -817,9 +817,7 @@ def gunicorn(c, address='0.0.0.0:8000', workers=None): Note: This server will not auto-reload in response to code changes. """ config_file = localDir().joinpath('contrib', 'container', 'gunicorn.conf.py') - cmd = ( - f'gunicorn -c {config_file} InvenTree.wsgi -b {address} --chdir {managePyDir()}' - ) + cmd = f'gunicorn -c {config_file} InvenTree.asgi -b {address} --chdir {managePyDir()} -k uvicorn_worker.UvicornWorker' if workers: cmd += f' --workers={workers}'