├── .gitignore ├── README.md ├── backend ├── Pipfile ├── Pipfile.lock ├── Procfile ├── api │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_auto_20210105_2352.py │ │ └── __init__.py │ ├── models.py │ ├── serializers.py │ ├── tests.py │ ├── urls.py │ └── views.py ├── group_call │ ├── __init__.py │ ├── asgi.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── manage.py ├── requirements.txt └── video_signalling │ ├── __init__.py │ ├── apps.py │ ├── consumers.py │ ├── migrations │ └── __init__.py │ ├── routing.py │ └── tests.py ├── frontend ├── package.json ├── public │ └── index.html ├── src │ ├── App.jsx │ ├── Routes.jsx │ ├── components │ │ ├── authentication │ │ │ ├── AuthenticationRoute.jsx │ │ │ ├── Login.jsx │ │ │ ├── Register.jsx │ │ │ ├── form_styles.jsx │ │ │ └── index.jsx │ │ ├── index.jsx │ │ ├── lobby │ │ │ ├── LobbyRoute.jsx │ │ │ ├── Room.jsx │ │ │ ├── RoomForm │ │ │ │ ├── CreateRoomForm.jsx │ │ │ │ └── create_room_form_styles.jsx │ │ │ ├── RoomList.jsx │ │ │ └── room_styles.jsx │ │ ├── navigation_bar │ │ │ ├── NavigationBar.jsx │ │ │ └── navigation_bar_styles.jsx │ │ ├── utilities │ │ │ ├── CONSTANTS.jsx │ │ │ ├── authForms_validation_schema.jsx │ │ │ ├── axios.jsx │ │ │ ├── components │ │ │ │ ├── Feedback.jsx │ │ │ │ ├── FormikUIField.jsx │ │ │ │ ├── FormikUISelect.jsx │ │ │ │ ├── Loading.jsx │ │ │ │ ├── RouterUILink.jsx │ │ │ │ ├── UserInfoProvider.jsx │ │ │ │ └── index.jsx │ │ │ ├── index.jsx │ │ │ └── roomForms_validation_schema.jsx │ │ └── video_room │ │ │ ├── Video.jsx │ │ │ ├── VideoRoom.jsx │ │ │ ├── VideoRoomRoute.jsx │ │ │ └── video_room_styles.jsx │ └── index.js └── yarn.lock └── readme_screenshots ├── screenshot16.png └── screenshot17.png /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | 3 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 4 | 5 | # dependencies 6 | /frontend/node_modules 7 | 8 | /.pnp 9 | .pnp.js 10 | 11 | # testing 12 | /frontend/coverage 13 | 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | 27 | 28 | # For Django 29 | 30 | # Created by https://www.toptal.com/developers/gitignore/api/django 31 | # Edit at https://www.toptal.com/developers/gitignore?templates=django 32 | 33 | ### Django ### 34 | *.log 35 | *.pot 36 | *.pyc 37 | __pycache__/ 38 | local_settings.py 39 | db.sqlite3 40 | db.sqlite3-journal 41 | media 42 | 43 | # If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ 44 | # in your Git repository. Update and uncomment the following line accordingly. 45 | # /staticfiles/ 46 | 47 | ### Django.Python Stack ### 48 | # Byte-compiled / optimized / DLL files 49 | *.py[cod] 50 | *$py.class 51 | 52 | # C extensions 53 | *.so 54 | 55 | # Distribution / packaging 56 | .Python 57 | develop-eggs/ 58 | dist/ 59 | downloads/ 60 | eggs/ 61 | .eggs/ 62 | lib/ 63 | lib64/ 64 | parts/ 65 | sdist/ 66 | var/ 67 | wheels/ 68 | pip-wheel-metadata/ 69 | share/python-wheels/ 70 | *.egg-info/ 71 | .installed.cfg 72 | *.egg 73 | MANIFEST 74 | 75 | # PyInstaller 76 | # Usually these files are written by a python script from a template 77 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 78 | *.manifest 79 | *.spec 80 | 81 | # Installer logs 82 | pip-log.txt 83 | pip-delete-this-directory.txt 84 | 85 | # Unit test / coverage reports 86 | htmlcov/ 87 | .tox/ 88 | .nox/ 89 | .coverage 90 | .coverage.* 91 | .cache 92 | nosetests.xml 93 | coverage.xml 94 | *.cover 95 | *.py,cover 96 | .hypothesis/ 97 | .pytest_cache/ 98 | pytestdebug.log 99 | 100 | # Translations 101 | *.mo 102 | 103 | # Django stuff: 104 | 105 | # Flask stuff: 106 | instance/ 107 | .webassets-cache 108 | 109 | # Scrapy stuff: 110 | .scrapy 111 | 112 | # Sphinx documentation 113 | docs/_build/ 114 | doc/_build/ 115 | 116 | # PyBuilder 117 | target/ 118 | 119 | # Jupyter Notebook 120 | .ipynb_checkpoints 121 | 122 | # IPython 123 | profile_default/ 124 | ipython_config.py 125 | 126 | # pyenv 127 | .python-version 128 | 129 | # pipenv 130 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 131 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 132 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 133 | # install all needed dependencies. 134 | #Pipfile.lock 135 | 136 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 137 | __pypackages__/ 138 | 139 | # Celery stuff 140 | celerybeat-schedule 141 | celerybeat.pid 142 | 143 | # SageMath parsed files 144 | *.sage.py 145 | 146 | # Environments 147 | .env 148 | .venv 149 | env/ 150 | venv/ 151 | ENV/ 152 | env.bak/ 153 | venv.bak/ 154 | pythonenv* 155 | 156 | # Spyder project settings 157 | .spyderproject 158 | .spyproject 159 | 160 | # Rope project settings 161 | .ropeproject 162 | 163 | # mkdocs documentation 164 | /site 165 | 166 | # mypy 167 | .mypy_cache/ 168 | .dmypy.json 169 | dmypy.json 170 | 171 | # Pyre type checker 172 | .pyre/ 173 | 174 | # pytype static type analyzer 175 | .pytype/ 176 | 177 | # profiling data 178 | .prof 179 | 180 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Group Call App 2 | 3 | ![screenshots](./readme_screenshots/screenshot16.png) 4 | 5 | ![screenshots](./readme_screenshots/screenshot17.png) 6 | 7 | (To run this project locally switch to 'dev' branch and goto to this [*README.md*](https://github.com/KyrinZ/react-django-webrtc-group-video-app/blob/dev/README.md) ) 8 | 9 | ## About the app 10 | 11 | This is one of my early app that I build. The main idea about the app is pretty straight forward, its a group call app. People create public or private rooms to communicate with each other via video. The purpose of making this app was to learn React, Django REST Framework, Websocket and WebRTC. Have no idea what those are? Me neither when I first started, and you don't have to understand it to use this app. 12 | 13 | This app was build as a final project for my course in CS50web. There is in depth explanation about the app source code and file structure in 'development-version' branch's [*README.md*](https://github.com/KyrinZero/react-django-webrtc-group-video-app/tree/development-version) file if you wanted to reproduce the app or build your own app based on this or just get an idea how I came to conclusion while building this app. **Warning**, It's pretty long. 14 | 15 | ## Technology overview 16 | 17 | The main technology I used here to pull off this project were React (A front-end library) and Django REST Framework (DRF is a Python based back-end framework). I used these two specifically due to constraints the course gave me. I can only use Django on back-end and any JavaScript framework on front-end. There is also another technology in addition to above technology I used that played a key role in group call feature, that is WebRTC. This technology responsible to connect one user browser to another user for group call. 18 | 19 | ## Challenges faced building the app 20 | 21 | It took around four months to learn and build this app. Before starting this project I didn't have any idea about React, DRF or WebRTC. WebRTC was the biggest hurdle that I faced. In theory its easy to understand what its doing but implementing with Django and React was difficult. Online didn't have good resources explaining how to implement WebRTC specifically with Django and React. I should have first made small todo or chat app to get the feel of the technology. Back of my mind I knew this project will be hard and I will fail several times making this but if I really wanna learn something and grow exponentially I can't go easy. But eventually I made it. 22 | 23 | This app isn't fancy or ground-breaking and for most professional building this will be a breeze. Still this app gave me good feel for most of the concept about building an app such as REST API, authentication, code splitting, code efficiency, UI/UX and more. 24 | -------------------------------------------------------------------------------- /backend/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.python.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | Django = "*" 8 | djangorestframework = "*" 9 | django-cors-headers = "*" 10 | djangorestframework-camel-case = "*" 11 | djangorestframework-simplejwt = "*" 12 | channels = "*" 13 | psycopg2-binary = "*" 14 | python-dotenv = "*" 15 | daphne = "*" 16 | channels-redis = "*" 17 | uvicorn = "*" 18 | 19 | [dev-packages] 20 | 21 | [requires] 22 | python_version = "3.8" 23 | -------------------------------------------------------------------------------- /backend/Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "4e51aaa031a154f1240f7549489e695c2fb9690251efed9dab19d5ee20229530" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.8" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.python.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "aioredis": { 20 | "hashes": [ 21 | "sha256:15f8af30b044c771aee6787e5ec24694c048184c7b9e54c3b60c750a4b93273a", 22 | "sha256:b61808d7e97b7cd5a92ed574937a079c9387fdadd22bfbfa7ad2fd319ecc26e3" 23 | ], 24 | "version": "==1.3.1" 25 | }, 26 | "asgiref": { 27 | "hashes": [ 28 | "sha256:5ee950735509d04eb673bd7f7120f8fa1c9e2df495394992c73234d526907e17", 29 | "sha256:7162a3cb30ab0609f1a4c95938fd73e8604f63bdba516a7f7d64b83ff09478f0" 30 | ], 31 | "version": "==3.3.1" 32 | }, 33 | "async-timeout": { 34 | "hashes": [ 35 | "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", 36 | "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3" 37 | ], 38 | "version": "==3.0.1" 39 | }, 40 | "attrs": { 41 | "hashes": [ 42 | "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", 43 | "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" 44 | ], 45 | "version": "==20.3.0" 46 | }, 47 | "autobahn": { 48 | "hashes": [ 49 | "sha256:410a93e0e29882c8b5d5ab05d220b07609b886ef5f23c0b8d39153254ffd6895", 50 | "sha256:52ee4236ff9a1fcbbd9500439dcf3284284b37f8a6b31ecc8a36e00cf9f95049" 51 | ], 52 | "version": "==20.12.3" 53 | }, 54 | "automat": { 55 | "hashes": [ 56 | "sha256:7979803c74610e11ef0c0d68a2942b152df52da55336e0c9d58daf1831cbdf33", 57 | "sha256:b6feb6455337df834f6c9962d6ccf771515b7d939bca142b29c20c2376bc6111" 58 | ], 59 | "version": "==20.2.0" 60 | }, 61 | "cffi": { 62 | "hashes": [ 63 | "sha256:00a1ba5e2e95684448de9b89888ccd02c98d512064b4cb987d48f4b40aa0421e", 64 | "sha256:00e28066507bfc3fe865a31f325c8391a1ac2916219340f87dfad602c3e48e5d", 65 | "sha256:045d792900a75e8b1e1b0ab6787dd733a8190ffcf80e8c8ceb2fb10a29ff238a", 66 | "sha256:0638c3ae1a0edfb77c6765d487fee624d2b1ee1bdfeffc1f0b58c64d149e7eec", 67 | "sha256:105abaf8a6075dc96c1fe5ae7aae073f4696f2905fde6aeada4c9d2926752362", 68 | "sha256:155136b51fd733fa94e1c2ea5211dcd4c8879869008fc811648f16541bf99668", 69 | "sha256:1a465cbe98a7fd391d47dce4b8f7e5b921e6cd805ef421d04f5f66ba8f06086c", 70 | "sha256:1d2c4994f515e5b485fd6d3a73d05526aa0fcf248eb135996b088d25dfa1865b", 71 | "sha256:2c24d61263f511551f740d1a065eb0212db1dbbbbd241db758f5244281590c06", 72 | "sha256:51a8b381b16ddd370178a65360ebe15fbc1c71cf6f584613a7ea08bfad946698", 73 | "sha256:594234691ac0e9b770aee9fcdb8fa02c22e43e5c619456efd0d6c2bf276f3eb2", 74 | "sha256:5cf4be6c304ad0b6602f5c4e90e2f59b47653ac1ed9c662ed379fe48a8f26b0c", 75 | "sha256:64081b3f8f6f3c3de6191ec89d7dc6c86a8a43911f7ecb422c60e90c70be41c7", 76 | "sha256:6bc25fc545a6b3d57b5f8618e59fc13d3a3a68431e8ca5fd4c13241cd70d0009", 77 | "sha256:798caa2a2384b1cbe8a2a139d80734c9db54f9cc155c99d7cc92441a23871c03", 78 | "sha256:7c6b1dece89874d9541fc974917b631406233ea0440d0bdfbb8e03bf39a49b3b", 79 | "sha256:840793c68105fe031f34d6a086eaea153a0cd5c491cde82a74b420edd0a2b909", 80 | "sha256:8d6603078baf4e11edc4168a514c5ce5b3ba6e3e9c374298cb88437957960a53", 81 | "sha256:9cc46bc107224ff5b6d04369e7c595acb700c3613ad7bcf2e2012f62ece80c35", 82 | "sha256:9f7a31251289b2ab6d4012f6e83e58bc3b96bd151f5b5262467f4bb6b34a7c26", 83 | "sha256:9ffb888f19d54a4d4dfd4b3f29bc2c16aa4972f1c2ab9c4ab09b8ab8685b9c2b", 84 | "sha256:a5ed8c05548b54b998b9498753fb9cadbfd92ee88e884641377d8a8b291bcc01", 85 | "sha256:a7711edca4dcef1a75257b50a2fbfe92a65187c47dab5a0f1b9b332c5919a3fb", 86 | "sha256:af5c59122a011049aad5dd87424b8e65a80e4a6477419c0c1015f73fb5ea0293", 87 | "sha256:b18e0a9ef57d2b41f5c68beefa32317d286c3d6ac0484efd10d6e07491bb95dd", 88 | "sha256:b4e248d1087abf9f4c10f3c398896c87ce82a9856494a7155823eb45a892395d", 89 | "sha256:ba4e9e0ae13fc41c6b23299545e5ef73055213e466bd107953e4a013a5ddd7e3", 90 | "sha256:c6332685306b6417a91b1ff9fae889b3ba65c2292d64bd9245c093b1b284809d", 91 | "sha256:d5ff0621c88ce83a28a10d2ce719b2ee85635e85c515f12bac99a95306da4b2e", 92 | "sha256:d9efd8b7a3ef378dd61a1e77367f1924375befc2eba06168b6ebfa903a5e59ca", 93 | "sha256:df5169c4396adc04f9b0a05f13c074df878b6052430e03f50e68adf3a57aa28d", 94 | "sha256:ebb253464a5d0482b191274f1c8bf00e33f7e0b9c66405fbffc61ed2c839c775", 95 | "sha256:ec80dc47f54e6e9a78181ce05feb71a0353854cc26999db963695f950b5fb375", 96 | "sha256:f032b34669220030f905152045dfa27741ce1a6db3324a5bc0b96b6c7420c87b", 97 | "sha256:f60567825f791c6f8a592f3c6e3bd93dd2934e3f9dac189308426bd76b00ef3b", 98 | "sha256:f803eaa94c2fcda012c047e62bc7a51b0bdabda1cad7a92a522694ea2d76e49f" 99 | ], 100 | "version": "==1.14.4" 101 | }, 102 | "channels": { 103 | "hashes": [ 104 | "sha256:056b72e51080a517a0f33a0a30003e03833b551d75394d6636c885d4edb8188f", 105 | "sha256:3f15bdd2138bb4796e76ea588a0a344b12a7964ea9b2e456f992fddb988a4317" 106 | ], 107 | "index": "pypi", 108 | "version": "==3.0.3" 109 | }, 110 | "channels-redis": { 111 | "hashes": [ 112 | "sha256:18d63f6462a58011740dc8eeb57ea4b31ec220eb551cb71b27de9c6779a549de", 113 | "sha256:2fb31a63b05373f6402da2e6a91a22b9e66eb8b56626c6bfc93e156c734c5ae6" 114 | ], 115 | "index": "pypi", 116 | "version": "==3.2.0" 117 | }, 118 | "click": { 119 | "hashes": [ 120 | "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", 121 | "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" 122 | ], 123 | "version": "==7.1.2" 124 | }, 125 | "constantly": { 126 | "hashes": [ 127 | "sha256:586372eb92059873e29eba4f9dec8381541b4d3834660707faf8ba59146dfc35", 128 | "sha256:dd2fa9d6b1a51a83f0d7dd76293d734046aa176e384bf6e33b7e44880eb37c5d" 129 | ], 130 | "version": "==15.1.0" 131 | }, 132 | "cryptography": { 133 | "hashes": [ 134 | "sha256:0003a52a123602e1acee177dc90dd201f9bb1e73f24a070db7d36c588e8f5c7d", 135 | "sha256:0e85aaae861d0485eb5a79d33226dd6248d2a9f133b81532c8f5aae37de10ff7", 136 | "sha256:594a1db4511bc4d960571536abe21b4e5c3003e8750ab8365fafce71c5d86901", 137 | "sha256:69e836c9e5ff4373ce6d3ab311c1a2eed274793083858d3cd4c7d12ce20d5f9c", 138 | "sha256:788a3c9942df5e4371c199d10383f44a105d67d401fb4304178020142f020244", 139 | "sha256:7e177e4bea2de937a584b13645cab32f25e3d96fc0bc4a4cf99c27dc77682be6", 140 | "sha256:83d9d2dfec70364a74f4e7c70ad04d3ca2e6a08b703606993407bf46b97868c5", 141 | "sha256:84ef7a0c10c24a7773163f917f1cb6b4444597efd505a8aed0a22e8c4780f27e", 142 | "sha256:9e21301f7a1e7c03dbea73e8602905a4ebba641547a462b26dd03451e5769e7c", 143 | "sha256:9f6b0492d111b43de5f70052e24c1f0951cb9e6022188ebcb1cc3a3d301469b0", 144 | "sha256:a69bd3c68b98298f490e84519b954335154917eaab52cf582fa2c5c7efc6e812", 145 | "sha256:b4890d5fb9b7a23e3bf8abf5a8a7da8e228f1e97dc96b30b95685df840b6914a", 146 | "sha256:c366df0401d1ec4e548bebe8f91d55ebcc0ec3137900d214dd7aac8427ef3030", 147 | "sha256:dc42f645f8f3a489c3dd416730a514e7a91a59510ddaadc09d04224c098d3302" 148 | ], 149 | "version": "==3.3.1" 150 | }, 151 | "daphne": { 152 | "hashes": [ 153 | "sha256:0052c9887600c57054a5867d4b0240159fa009faa3bcf6a1627271d9cdcb005a", 154 | "sha256:c22b692707f514de9013651ecb687f2abe4f35cf6fe292ece634e9f1737bc7e3" 155 | ], 156 | "index": "pypi", 157 | "version": "==3.0.1" 158 | }, 159 | "django": { 160 | "hashes": [ 161 | "sha256:2d78425ba74c7a1a74b196058b261b9733a8570782f4e2828974777ccca7edf7", 162 | "sha256:efa2ab96b33b20c2182db93147a0c3cd7769d418926f9e9f140a60dca7c64ca9" 163 | ], 164 | "index": "pypi", 165 | "version": "==3.1.5" 166 | }, 167 | "django-cors-headers": { 168 | "hashes": [ 169 | "sha256:5665fc1b1aabf1b678885cf6f8f8bd7da36ef0a978375e767d491b48d3055d8f", 170 | "sha256:ba898dd478cd4be3a38ebc3d8729fa4d044679f8c91b2684edee41129d7e968a" 171 | ], 172 | "index": "pypi", 173 | "version": "==3.6.0" 174 | }, 175 | "djangorestframework": { 176 | "hashes": [ 177 | "sha256:0209bafcb7b5010fdfec784034f059d512256424de2a0f084cb82b096d6dd6a7", 178 | "sha256:0898182b4737a7b584a2c73735d89816343369f259fea932d90dc78e35d8ac33" 179 | ], 180 | "index": "pypi", 181 | "version": "==3.12.2" 182 | }, 183 | "djangorestframework-camel-case": { 184 | "hashes": [ 185 | "sha256:9714d43fba5bb654057c29501649684d3d9f11a92319ae417fd4d65e80d1159d" 186 | ], 187 | "index": "pypi", 188 | "version": "==1.2.0" 189 | }, 190 | "djangorestframework-simplejwt": { 191 | "hashes": [ 192 | "sha256:7adc913ba0d2ed7f46e0b9bf6e86f9bd9248f1c4201722b732b8213e0ea66f9f", 193 | "sha256:bd587700b6ab34a6c6b12d426cce4fa580d57ef1952ad4ba3b79707784619ed3" 194 | ], 195 | "index": "pypi", 196 | "version": "==4.6.0" 197 | }, 198 | "h11": { 199 | "hashes": [ 200 | "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6", 201 | "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042" 202 | ], 203 | "version": "==0.12.0" 204 | }, 205 | "hiredis": { 206 | "hashes": [ 207 | "sha256:06a039208f83744a702279b894c8cf24c14fd63c59cd917dcde168b79eef0680", 208 | "sha256:0a909bf501459062aa1552be1461456518f367379fdc9fdb1f2ca5e4a1fdd7c0", 209 | "sha256:18402d9e54fb278cb9a8c638df6f1550aca36a009d47ecf5aa263a38600f35b0", 210 | "sha256:1e4cbbc3858ec7e680006e5ca590d89a5e083235988f26a004acf7244389ac01", 211 | "sha256:23344e3c2177baf6975fbfa361ed92eb7d36d08f454636e5054b3faa7c2aff8a", 212 | "sha256:289b31885b4996ce04cadfd5fc03d034dce8e2a8234479f7c9e23b9e245db06b", 213 | "sha256:2c1c570ae7bf1bab304f29427e2475fe1856814312c4a1cf1cd0ee133f07a3c6", 214 | "sha256:2c227c0ed371771ffda256034427320870e8ea2e4fd0c0a618c766e7c49aad73", 215 | "sha256:3bb9b63d319402cead8bbd9dd55dca3b667d2997e9a0d8a1f9b6cc274db4baee", 216 | "sha256:3ef2183de67b59930d2db8b8e8d4d58e00a50fcc5e92f4f678f6eed7a1c72d55", 217 | "sha256:43b8ed3dbfd9171e44c554cb4acf4ee4505caa84c5e341858b50ea27dd2b6e12", 218 | "sha256:47bcf3c5e6c1e87ceb86cdda2ee983fa0fe56a999e6185099b3c93a223f2fa9b", 219 | "sha256:5263db1e2e1e8ae30500cdd75a979ff99dcc184201e6b4b820d0de74834d2323", 220 | "sha256:5b1451727f02e7acbdf6aae4e06d75f66ee82966ff9114550381c3271a90f56c", 221 | "sha256:6996883a8a6ff9117cbb3d6f5b0dcbbae6fb9e31e1a3e4e2f95e0214d9a1c655", 222 | "sha256:6c96f64a54f030366657a54bb90b3093afc9c16c8e0dfa29fc0d6dbe169103a5", 223 | "sha256:7332d5c3e35154cd234fd79573736ddcf7a0ade7a986db35b6196b9171493e75", 224 | "sha256:7885b6f32c4a898e825bb7f56f36a02781ac4a951c63e4169f0afcf9c8c30dfb", 225 | "sha256:7b0f63f10a166583ab744a58baad04e0f52cfea1ac27bfa1b0c21a48d1003c23", 226 | "sha256:819f95d4eba3f9e484dd115ab7ab72845cf766b84286a00d4ecf76d33f1edca1", 227 | "sha256:8968eeaa4d37a38f8ca1f9dbe53526b69628edc9c42229a5b2f56d98bb828c1f", 228 | "sha256:89ebf69cb19a33d625db72d2ac589d26e936b8f7628531269accf4a3196e7872", 229 | "sha256:8daecd778c1da45b8bd54fd41ffcd471a86beed3d8e57a43acf7a8d63bba4058", 230 | "sha256:955ba8ea73cf3ed8bd2f963b4cb9f8f0dcb27becd2f4b3dd536fd24c45533454", 231 | "sha256:964f18a59f5a64c0170f684c417f4fe3e695a536612e13074c4dd5d1c6d7c882", 232 | "sha256:969843fbdfbf56cdb71da6f0bdf50f9985b8b8aeb630102945306cf10a9c6af2", 233 | "sha256:996021ef33e0f50b97ff2d6b5f422a0fe5577de21a8873b58a779a5ddd1c3132", 234 | "sha256:9e9c9078a7ce07e6fce366bd818be89365a35d2e4b163268f0ca9ba7e13bb2f6", 235 | "sha256:a04901757cb0fb0f5602ac11dda48f5510f94372144d06c2563ba56c480b467c", 236 | "sha256:a7bf1492429f18d205f3a818da3ff1f242f60aa59006e53dee00b4ef592a3363", 237 | "sha256:aa0af2deb166a5e26e0d554b824605e660039b161e37ed4f01b8d04beec184f3", 238 | "sha256:abfb15a6a7822f0fae681785cb38860e7a2cb1616a708d53df557b3d76c5bfd4", 239 | "sha256:b253fe4df2afea4dfa6b1fa8c5fef212aff8bcaaeb4207e81eed05cb5e4a7919", 240 | "sha256:b27f082f47d23cffc4cf1388b84fdc45c4ef6015f906cd7e0d988d9e35d36349", 241 | "sha256:b33aea449e7f46738811fbc6f0b3177c6777a572207412bbbf6f525ffed001ae", 242 | "sha256:b44f9421c4505c548435244d74037618f452844c5d3c67719d8a55e2613549da", 243 | "sha256:bcc371151d1512201d0214c36c0c150b1dc64f19c2b1a8c9cb1d7c7c15ebd93f", 244 | "sha256:c2851deeabd96d3f6283e9c6b26e0bfed4de2dc6fb15edf913e78b79fc5909ed", 245 | "sha256:cdfd501c7ac5b198c15df800a3a34c38345f5182e5f80770caf362bccca65628", 246 | "sha256:d2c0caffa47606d6d7c8af94ba42547bd2a441f06c74fd90a1ffe328524a6c64", 247 | "sha256:dcb2db95e629962db5a355047fb8aefb012df6c8ae608930d391619dbd96fd86", 248 | "sha256:e0eeb9c112fec2031927a1745788a181d0eecbacbed941fc5c4f7bc3f7b273bf", 249 | "sha256:e154891263306200260d7f3051982774d7b9ef35af3509d5adbbe539afd2610c", 250 | "sha256:e2e023a42dcbab8ed31f97c2bcdb980b7fbe0ada34037d87ba9d799664b58ded", 251 | "sha256:e64be68255234bb489a574c4f2f8df7029c98c81ec4d160d6cd836e7f0679390", 252 | "sha256:e82d6b930e02e80e5109b678c663a9ed210680ded81c1abaf54635d88d1da298" 253 | ], 254 | "version": "==1.1.0" 255 | }, 256 | "hyperlink": { 257 | "hashes": [ 258 | "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b", 259 | "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4" 260 | ], 261 | "version": "==21.0.0" 262 | }, 263 | "idna": { 264 | "hashes": [ 265 | "sha256:5205d03e7bcbb919cc9c19885f9920d622ca52448306f2377daede5cf3faac16", 266 | "sha256:c5b02147e01ea9920e6b0a3f1f7bb833612d507592c837a6c49552768f4054e1" 267 | ], 268 | "version": "==3.1" 269 | }, 270 | "incremental": { 271 | "hashes": [ 272 | "sha256:717e12246dddf231a349175f48d74d93e2897244939173b01974ab6661406b9f", 273 | "sha256:7b751696aaf36eebfab537e458929e194460051ccad279c72b755a167eebd4b3" 274 | ], 275 | "version": "==17.5.0" 276 | }, 277 | "msgpack": { 278 | "hashes": [ 279 | "sha256:0cb94ee48675a45d3b86e61d13c1e6f1696f0183f0715544976356ff86f741d9", 280 | "sha256:1026dcc10537d27dd2d26c327e552f05ce148977e9d7b9f1718748281b38c841", 281 | "sha256:26a1759f1a88df5f1d0b393eb582ec022326994e311ba9c5818adc5374736439", 282 | "sha256:2a5866bdc88d77f6e1370f82f2371c9bc6fc92fe898fa2dec0c5d4f5435a2694", 283 | "sha256:31c17bbf2ae5e29e48d794c693b7ca7a0c73bd4280976d408c53df421e838d2a", 284 | "sha256:497d2c12426adcd27ab83144057a705efb6acc7e85957a51d43cdcf7f258900f", 285 | "sha256:5a9ee2540c78659a1dd0b110f73773533ee3108d4e1219b5a15a8d635b7aca0e", 286 | "sha256:8521e5be9e3b93d4d5e07cb80b7e32353264d143c1f072309e1863174c6aadb1", 287 | "sha256:87869ba567fe371c4555d2e11e4948778ab6b59d6cc9d8460d543e4cfbbddd1c", 288 | "sha256:8ffb24a3b7518e843cd83538cf859e026d24ec41ac5721c18ed0c55101f9775b", 289 | "sha256:92be4b12de4806d3c36810b0fe2aeedd8d493db39e2eb90742b9c09299eb5759", 290 | "sha256:9ea52fff0473f9f3000987f313310208c879493491ef3ccf66268eff8d5a0326", 291 | "sha256:a4355d2193106c7aa77c98fc955252a737d8550320ecdb2e9ac701e15e2943bc", 292 | "sha256:a99b144475230982aee16b3d249170f1cccebf27fb0a08e9f603b69637a62192", 293 | "sha256:ac25f3e0513f6673e8b405c3a80500eb7be1cf8f57584be524c4fa78fe8e0c83", 294 | "sha256:b28c0876cce1466d7c2195d7658cf50e4730667196e2f1355c4209444717ee06", 295 | "sha256:b55f7db883530b74c857e50e149126b91bb75d35c08b28db12dcb0346f15e46e", 296 | "sha256:b6d9e2dae081aa35c44af9c4298de4ee72991305503442a5c74656d82b581fe9", 297 | "sha256:c747c0cc08bd6d72a586310bda6ea72eeb28e7505990f342552315b229a19b33", 298 | "sha256:d6c64601af8f3893d17ec233237030e3110f11b8a962cb66720bf70c0141aa54", 299 | "sha256:d8167b84af26654c1124857d71650404336f4eb5cc06900667a493fc619ddd9f", 300 | "sha256:de6bd7990a2c2dabe926b7e62a92886ccbf809425c347ae7de277067f97c2887", 301 | "sha256:e36a812ef4705a291cdb4a2fd352f013134f26c6ff63477f20235138d1d21009", 302 | "sha256:e89ec55871ed5473a041c0495b7b4e6099f6263438e0bd04ccd8418f92d5d7f2", 303 | "sha256:f3e6aaf217ac1c7ce1563cf52a2f4f5d5b1f64e8729d794165db71da57257f0c", 304 | "sha256:f484cd2dca68502de3704f056fa9b318c94b1539ed17a4c784266df5d6978c87", 305 | "sha256:fae04496f5bc150eefad4e9571d1a76c55d021325dcd484ce45065ebbdd00984", 306 | "sha256:fe07bc6735d08e492a327f496b7850e98cb4d112c56df69b0c844dbebcbb47f6" 307 | ], 308 | "version": "==1.0.2" 309 | }, 310 | "psycopg2-binary": { 311 | "hashes": [ 312 | "sha256:0deac2af1a587ae12836aa07970f5cb91964f05a7c6cdb69d8425ff4c15d4e2c", 313 | "sha256:0e4dc3d5996760104746e6cfcdb519d9d2cd27c738296525d5867ea695774e67", 314 | "sha256:11b9c0ebce097180129e422379b824ae21c8f2a6596b159c7659e2e5a00e1aa0", 315 | "sha256:15978a1fbd225583dd8cdaf37e67ccc278b5abecb4caf6b2d6b8e2b948e953f6", 316 | "sha256:1fabed9ea2acc4efe4671b92c669a213db744d2af8a9fc5d69a8e9bc14b7a9db", 317 | "sha256:2dac98e85565d5688e8ab7bdea5446674a83a3945a8f416ad0110018d1501b94", 318 | "sha256:42ec1035841b389e8cc3692277a0bd81cdfe0b65d575a2c8862cec7a80e62e52", 319 | "sha256:6422f2ff0919fd720195f64ffd8f924c1395d30f9a495f31e2392c2efafb5056", 320 | "sha256:6a32f3a4cb2f6e1a0b15215f448e8ce2da192fd4ff35084d80d5e39da683e79b", 321 | "sha256:7312e931b90fe14f925729cde58022f5d034241918a5c4f9797cac62f6b3a9dd", 322 | "sha256:7d92a09b788cbb1aec325af5fcba9fed7203897bbd9269d5691bb1e3bce29550", 323 | "sha256:833709a5c66ca52f1d21d41865a637223b368c0ee76ea54ca5bad6f2526c7679", 324 | "sha256:89705f45ce07b2dfa806ee84439ec67c5d9a0ef20154e0e475e2b2ed392a5b83", 325 | "sha256:8cd0fb36c7412996859cb4606a35969dd01f4ea34d9812a141cd920c3b18be77", 326 | "sha256:950bc22bb56ee6ff142a2cb9ee980b571dd0912b0334aa3fe0fe3788d860bea2", 327 | "sha256:a0c50db33c32594305b0ef9abc0cb7db13de7621d2cadf8392a1d9b3c437ef77", 328 | "sha256:a0eb43a07386c3f1f1ebb4dc7aafb13f67188eab896e7397aa1ee95a9c884eb2", 329 | "sha256:aaa4213c862f0ef00022751161df35804127b78adf4a2755b9f991a507e425fd", 330 | "sha256:ac0c682111fbf404525dfc0f18a8b5f11be52657d4f96e9fcb75daf4f3984859", 331 | "sha256:ad20d2eb875aaa1ea6d0f2916949f5c08a19c74d05b16ce6ebf6d24f2c9f75d1", 332 | "sha256:b4afc542c0ac0db720cf516dd20c0846f71c248d2b3d21013aa0d4ef9c71ca25", 333 | "sha256:b8a3715b3c4e604bcc94c90a825cd7f5635417453b253499664f784fc4da0152", 334 | "sha256:ba28584e6bca48c59eecbf7efb1576ca214b47f05194646b081717fa628dfddf", 335 | "sha256:ba381aec3a5dc29634f20692349d73f2d21f17653bda1decf0b52b11d694541f", 336 | "sha256:bd1be66dde2b82f80afb9459fc618216753f67109b859a361cf7def5c7968729", 337 | "sha256:c2507d796fca339c8fb03216364cca68d87e037c1f774977c8fc377627d01c71", 338 | "sha256:cec7e622ebc545dbb4564e483dd20e4e404da17ae07e06f3e780b2dacd5cee66", 339 | "sha256:d14b140a4439d816e3b1229a4a525df917d6ea22a0771a2a78332273fd9528a4", 340 | "sha256:d1b4ab59e02d9008efe10ceabd0b31e79519da6fb67f7d8e8977118832d0f449", 341 | "sha256:d5227b229005a696cc67676e24c214740efd90b148de5733419ac9aaba3773da", 342 | "sha256:e1f57aa70d3f7cc6947fd88636a481638263ba04a742b4a37dd25c373e41491a", 343 | "sha256:e74a55f6bad0e7d3968399deb50f61f4db1926acf4a6d83beaaa7df986f48b1c", 344 | "sha256:e82aba2188b9ba309fd8e271702bd0d0fc9148ae3150532bbb474f4590039ffb", 345 | "sha256:ee69dad2c7155756ad114c02db06002f4cded41132cc51378e57aad79cc8e4f4", 346 | "sha256:f5ab93a2cb2d8338b1674be43b442a7f544a0971da062a5da774ed40587f18f5" 347 | ], 348 | "index": "pypi", 349 | "version": "==2.8.6" 350 | }, 351 | "pyasn1": { 352 | "hashes": [ 353 | "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359", 354 | "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576", 355 | "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf", 356 | "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7", 357 | "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", 358 | "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00", 359 | "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8", 360 | "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86", 361 | "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12", 362 | "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776", 363 | "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", 364 | "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2", 365 | "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3" 366 | ], 367 | "version": "==0.4.8" 368 | }, 369 | "pyasn1-modules": { 370 | "hashes": [ 371 | "sha256:0845a5582f6a02bb3e1bde9ecfc4bfcae6ec3210dd270522fee602365430c3f8", 372 | "sha256:0fe1b68d1e486a1ed5473f1302bd991c1611d319bba158e98b106ff86e1d7199", 373 | "sha256:15b7c67fabc7fc240d87fb9aabf999cf82311a6d6fb2c70d00d3d0604878c811", 374 | "sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed", 375 | "sha256:65cebbaffc913f4fe9e4808735c95ea22d7a7775646ab690518c056784bc21b4", 376 | "sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e", 377 | "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74", 378 | "sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb", 379 | "sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45", 380 | "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd", 381 | "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0", 382 | "sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d", 383 | "sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405" 384 | ], 385 | "version": "==0.2.8" 386 | }, 387 | "pycparser": { 388 | "hashes": [ 389 | "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", 390 | "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" 391 | ], 392 | "version": "==2.20" 393 | }, 394 | "pyhamcrest": { 395 | "hashes": [ 396 | "sha256:412e00137858f04bde0729913874a48485665f2d36fe9ee449f26be864af9316", 397 | "sha256:7ead136e03655af85069b6f47b23eb7c3e5c221aa9f022a4fbb499f5b7308f29" 398 | ], 399 | "version": "==2.0.2" 400 | }, 401 | "pyjwt": { 402 | "hashes": [ 403 | "sha256:a5c70a06e1f33d81ef25eecd50d50bd30e34de1ca8b2b9fa3fe0daaabcf69bf7", 404 | "sha256:b70b15f89dc69b993d8a8d32c299032d5355c82f9b5b7e851d1a6d706dffe847" 405 | ], 406 | "version": "==2.0.1" 407 | }, 408 | "pyopenssl": { 409 | "hashes": [ 410 | "sha256:4c231c759543ba02560fcd2480c48dcec4dae34c9da7d3747c508227e0624b51", 411 | "sha256:818ae18e06922c066f777a33f1fca45786d85edfe71cd043de6379337a7f274b" 412 | ], 413 | "version": "==20.0.1" 414 | }, 415 | "python-dotenv": { 416 | "hashes": [ 417 | "sha256:0c8d1b80d1a1e91717ea7d526178e3882732420b03f08afea0406db6402e220e", 418 | "sha256:587825ed60b1711daea4832cf37524dfd404325b7db5e25ebe88c495c9f807a0" 419 | ], 420 | "index": "pypi", 421 | "version": "==0.15.0" 422 | }, 423 | "pytz": { 424 | "hashes": [ 425 | "sha256:16962c5fb8db4a8f63a26646d8886e9d769b6c511543557bc84e9569fb9a9cb4", 426 | "sha256:180befebb1927b16f6b57101720075a984c019ac16b1b7575673bea42c6c3da5" 427 | ], 428 | "version": "==2020.5" 429 | }, 430 | "service-identity": { 431 | "hashes": [ 432 | "sha256:001c0707759cb3de7e49c078a7c0c9cd12594161d3bf06b9c254fdcb1a60dc36", 433 | "sha256:0858a54aabc5b459d1aafa8a518ed2081a285087f349fe3e55197989232e2e2d" 434 | ], 435 | "version": "==18.1.0" 436 | }, 437 | "six": { 438 | "hashes": [ 439 | "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", 440 | "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" 441 | ], 442 | "version": "==1.15.0" 443 | }, 444 | "sqlparse": { 445 | "hashes": [ 446 | "sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0", 447 | "sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8" 448 | ], 449 | "version": "==0.4.1" 450 | }, 451 | "twisted": { 452 | "hashes": [ 453 | "sha256:040eb6641125d2a9a09cf198ec7b83dd8858c6f51f6770325ed9959c00f5098f", 454 | "sha256:147780b8caf21ba2aef3688628eaf13d7e7fe02a86747cd54bfaf2140538f042", 455 | "sha256:158ddb80719a4813d292293ac44ba41d8b56555ed009d90994a278237ee63d2c", 456 | "sha256:2182000d6ffc05d269e6c03bfcec8b57e20259ca1086180edaedec3f1e689292", 457 | "sha256:25ffcf37944bdad4a99981bc74006d735a678d2b5c193781254fbbb6d69e3b22", 458 | "sha256:3281d9ce889f7b21bdb73658e887141aa45a102baf3b2320eafcfba954fcefec", 459 | "sha256:356e8d8dd3590e790e3dba4db139eb8a17aca64b46629c622e1b1597a4a92478", 460 | "sha256:70952c56e4965b9f53b180daecf20a9595cf22b8d0935cd3bd664c90273c3ab2", 461 | "sha256:7408c6635ee1b96587289283ebe90ee15dbf9614b05857b446055116bc822d29", 462 | "sha256:7c547fd0215db9da8a1bc23182b309e84a232364cc26d829e9ee196ce840b114", 463 | "sha256:894f6f3cfa57a15ea0d0714e4283913a5f2511dbd18653dd148eba53b3919797", 464 | "sha256:94ac3d55a58c90e2075c5fe1853f2aa3892b73e3bf56395f743aefde8605eeaa", 465 | "sha256:a58e61a2a01e5bcbe3b575c0099a2bcb8d70a75b1a087338e0c48dd6e01a5f15", 466 | "sha256:c09c47ff9750a8e3aa60ad169c4b95006d455a29b80ad0901f031a103b2991cd", 467 | "sha256:ca3a0b8c9110800e576d89b5337373e52018b41069bc879f12fa42b7eb2d0274", 468 | "sha256:cd1dc5c85b58494138a3917752b54bb1daa0045d234b7c132c37a61d5483ebad", 469 | "sha256:cdbc4c7f0cd7a2218b575844e970f05a1be1861c607b0e048c9bceca0c4d42f7", 470 | "sha256:d267125cc0f1e8a0eed6319ba4ac7477da9b78a535601c49ecd20c875576433a", 471 | "sha256:d72c55b5d56e176563b91d11952d13b01af8725c623e498db5507b6614fc1e10", 472 | "sha256:d95803193561a243cb0401b0567c6b7987d3f2a67046770e1dccd1c9e49a9780", 473 | "sha256:e92703bed0cc21d6cb5c61d66922b3b1564015ca8a51325bd164a5e33798d504", 474 | "sha256:f058bd0168271de4dcdc39845b52dd0a4a2fecf5f1246335f13f5e96eaebb467", 475 | "sha256:f3c19e5bd42bbe4bf345704ad7c326c74d3fd7a1b3844987853bef180be638d4" 476 | ], 477 | "version": "==20.3.0" 478 | }, 479 | "txaio": { 480 | "hashes": [ 481 | "sha256:1488d31d564a116538cc1265ac3f7979fb6223bb5a9e9f1479436ee2c17d8549", 482 | "sha256:a8676d6c68aea1f0e2548c4afdb8e6253873af3bc2659bb5bcd9f39dff7ff90f" 483 | ], 484 | "version": "==20.12.1" 485 | }, 486 | "uvicorn": { 487 | "hashes": [ 488 | "sha256:1079c50a06f6338095b4f203e7861dbff318dde5f22f3a324fc6e94c7654164c", 489 | "sha256:ef1e0bb5f7941c6fe324e06443ddac0331e1632a776175f87891c7bd02694355" 490 | ], 491 | "index": "pypi", 492 | "version": "==0.13.3" 493 | }, 494 | "zope.interface": { 495 | "hashes": [ 496 | "sha256:05a97ba92c1c7c26f25c9f671aa1ef85ffead6cdad13770e5b689cf983adc7e1", 497 | "sha256:07d61722dd7d85547b7c6b0f5486b4338001fab349f2ac5cabc0b7182eb3425d", 498 | "sha256:0a990dcc97806e5980bbb54b2e46b9cde9e48932d8e6984daf71ef1745516123", 499 | "sha256:150e8bcb7253a34a4535aeea3de36c0bb3b1a6a47a183a95d65a194b3e07f232", 500 | "sha256:1743bcfe45af8846b775086471c28258f4c6e9ee8ef37484de4495f15a98b549", 501 | "sha256:1b5f6c8fff4ed32aa2dd43e84061bc8346f32d3ba6ad6e58f088fe109608f102", 502 | "sha256:21e49123f375703cf824214939d39df0af62c47d122d955b2a8d9153ea08cfd5", 503 | "sha256:21f579134a47083ffb5ddd1307f0405c91aa8b61ad4be6fd5af0171474fe0c45", 504 | "sha256:27c267dc38a0f0079e96a2945ee65786d38ef111e413c702fbaaacbab6361d00", 505 | "sha256:299bde0ab9e5c4a92f01a152b7fbabb460f31343f1416f9b7b983167ab1e33bc", 506 | "sha256:2ab88d8f228f803fcb8cb7d222c579d13dab2d3622c51e8cf321280da01102a7", 507 | "sha256:2ced4c35061eea623bc84c7711eedce8ecc3c2c51cd9c6afa6290df3bae9e104", 508 | "sha256:2dcab01c660983ba5e5a612e0c935141ccbee67d2e2e14b833e01c2354bd8034", 509 | "sha256:32546af61a9a9b141ca38d971aa6eb9800450fa6620ce6323cc30eec447861f3", 510 | "sha256:32b40a4c46d199827d79c86bb8cb88b1bbb764f127876f2cb6f3a47f63dbada3", 511 | "sha256:3cc94c69f6bd48ed86e8e24f358cb75095c8129827df1298518ab860115269a4", 512 | "sha256:42b278ac0989d6f5cf58d7e0828ea6b5951464e3cf2ff229dd09a96cb6ba0c86", 513 | "sha256:495b63fd0302f282ee6c1e6ea0f1c12cb3d1a49c8292d27287f01845ff252a96", 514 | "sha256:4af87cdc0d4b14e600e6d3d09793dce3b7171348a094ba818e2a68ae7ee67546", 515 | "sha256:4b94df9f2fdde7b9314321bab8448e6ad5a23b80542dcab53e329527d4099dcb", 516 | "sha256:4c48ddb63e2b20fba4c6a2bf81b4d49e99b6d4587fb67a6cd33a2c1f003af3e3", 517 | "sha256:4df9afd17bd5477e9f8c8b6bb8507e18dd0f8b4efe73bb99729ff203279e9e3b", 518 | "sha256:518950fe6a5d56f94ba125107895f938a4f34f704c658986eae8255edb41163b", 519 | "sha256:538298e4e113ccb8b41658d5a4b605bebe75e46a30ceca22a5a289cf02c80bec", 520 | "sha256:55465121e72e208a7b69b53de791402affe6165083b2ea71b892728bd19ba9ae", 521 | "sha256:588384d70a0f19b47409cfdb10e0c27c20e4293b74fc891df3d8eb47782b8b3e", 522 | "sha256:6278c080d4afffc9016e14325f8734456831124e8c12caa754fd544435c08386", 523 | "sha256:64ea6c221aeee4796860405e1aedec63424cda4202a7ad27a5066876db5b0fd2", 524 | "sha256:681dbb33e2b40262b33fd383bae63c36d33fd79fa1a8e4092945430744ffd34a", 525 | "sha256:6936aa9da390402d646a32a6a38d5409c2d2afb2950f045a7d02ab25a4e7d08d", 526 | "sha256:778d0ec38bbd288b150a3ae363c8ffd88d2207a756842495e9bffd8a8afbc89a", 527 | "sha256:8251f06a77985a2729a8bdbefbae79ee78567dddc3acbd499b87e705ca59fe24", 528 | "sha256:83b4aa5344cce005a9cff5d0321b2e318e871cc1dfc793b66c32dd4f59e9770d", 529 | "sha256:844fad925ac5c2ad4faaceb3b2520ad016b5280105c6e16e79838cf951903a7b", 530 | "sha256:8ceb3667dd13b8133f2e4d637b5b00f240f066448e2aa89a41f4c2d78a26ce50", 531 | "sha256:92dc0fb79675882d0b6138be4bf0cec7ea7c7eede60aaca78303d8e8dbdaa523", 532 | "sha256:9789bd945e9f5bd026ed3f5b453d640befb8b1fc33a779c1fe8d3eb21fe3fb4a", 533 | "sha256:a2b6d6eb693bc2fc6c484f2e5d93bd0b0da803fa77bf974f160533e555e4d095", 534 | "sha256:aab9f1e34d810feb00bf841993552b8fcc6ae71d473c505381627143d0018a6a", 535 | "sha256:abb61afd84f23099ac6099d804cdba9bd3b902aaaded3ffff47e490b0a495520", 536 | "sha256:adf9ee115ae8ff8b6da4b854b4152f253b390ba64407a22d75456fe07dcbda65", 537 | "sha256:aedc6c672b351afe6dfe17ff83ee5e7eb6ed44718f879a9328a68bdb20b57e11", 538 | "sha256:b7a00ecb1434f8183395fac5366a21ee73d14900082ca37cf74993cf46baa56c", 539 | "sha256:ba32f4a91c1cb7314c429b03afbf87b1fff4fb1c8db32260e7310104bd77f0c7", 540 | "sha256:cbd0f2cbd8689861209cd89141371d3a22a11613304d1f0736492590aa0ab332", 541 | "sha256:e4bc372b953bf6cec65a8d48482ba574f6e051621d157cf224227dbb55486b1e", 542 | "sha256:eccac3d9aadc68e994b6d228cb0c8919fc47a5350d85a1b4d3d81d1e98baf40c", 543 | "sha256:efd550b3da28195746bb43bd1d815058181a7ca6d9d6aa89dd37f5eefe2cacb7", 544 | "sha256:efef581c8ba4d990770875e1a2218e856849d32ada2680e53aebc5d154a17e20", 545 | "sha256:f057897711a630a0b7a6a03f1acf379b6ba25d37dc5dc217a97191984ba7f2fc", 546 | "sha256:f37d45fab14ffef9d33a0dc3bc59ce0c5313e2253323312d47739192da94f5fd", 547 | "sha256:f44906f70205d456d503105023041f1e63aece7623b31c390a0103db4de17537" 548 | ], 549 | "version": "==5.2.0" 550 | } 551 | }, 552 | "develop": {} 553 | } 554 | -------------------------------------------------------------------------------- /backend/Procfile: -------------------------------------------------------------------------------- 1 | release: python manage.py migrate 2 | web: daphne group_call.asgi:application --port $PORT --bind 0.0.0.0 -v2 3 | worker: python manage.py runworker channels --settings=group_call.settings -v2 -------------------------------------------------------------------------------- /backend/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlad99764/full-stack-react-django/2daa7139a3121539e13797c3769689ba4639c32d/backend/api/__init__.py -------------------------------------------------------------------------------- /backend/api/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | from .models import Room, User 6 | 7 | 8 | # Custom Admin UI for USER 9 | @admin.register(User) 10 | class UserAdmin(DjangoUserAdmin): 11 | """Define admin model for custom User model with no email field.""" 12 | 13 | fieldsets = ( 14 | (None, {"fields": ("email", "password")}), 15 | (_("Personal info"), {"fields": ("first_name", "last_name")}), 16 | ( 17 | _("Permissions"), 18 | { 19 | "fields": ( 20 | "is_active", 21 | "is_staff", 22 | "is_superuser", 23 | "groups", 24 | "user_permissions", 25 | ) 26 | }, 27 | ), 28 | (_("Important dates"), {"fields": ("last_login", "date_joined")}), 29 | ) 30 | add_fieldsets = ( 31 | ( 32 | None, 33 | { 34 | "classes": ("wide",), 35 | "fields": ("email", "password1", "password2"), 36 | }, 37 | ), 38 | ) 39 | list_display = ("email", "first_name", "last_name", "is_staff") 40 | search_fields = ("email", "first_name", "last_name") 41 | ordering = ("email",) 42 | 43 | 44 | admin.site.register(Room) 45 | -------------------------------------------------------------------------------- /backend/api/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ApiConfig(AppConfig): 5 | name = 'api' 6 | -------------------------------------------------------------------------------- /backend/api/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-11-27 14:33 2 | 3 | import api.models 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | import django.utils.timezone 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | ('auth', '0012_alter_user_first_name_max_length'), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='User', 21 | fields=[ 22 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 23 | ('password', models.CharField(max_length=128, verbose_name='password')), 24 | ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), 25 | ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), 26 | ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), 27 | ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), 28 | ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), 29 | ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), 30 | ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), 31 | ('email', models.EmailField(max_length=254, unique=True, verbose_name='email address')), 32 | ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), 33 | ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), 34 | ], 35 | options={ 36 | 'verbose_name': 'user', 37 | 'verbose_name_plural': 'users', 38 | 'abstract': False, 39 | }, 40 | managers=[ 41 | ('objects', api.models.UserManager()), 42 | ], 43 | ), 44 | migrations.CreateModel( 45 | name='Room', 46 | fields=[ 47 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 48 | ('title', models.CharField(max_length=200)), 49 | ('description', models.TextField(default='')), 50 | ('type_of', models.CharField(choices=[('OTA', 'Open to all'), ('IO', 'Invite only'), ('L', 'Locked')], default='OTA', max_length=3)), 51 | ('created_on', models.DateTimeField(auto_now_add=True)), 52 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 53 | ], 54 | ), 55 | ] 56 | -------------------------------------------------------------------------------- /backend/api/migrations/0002_auto_20210105_2352.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.5 on 2021-01-05 23:52 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('api', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='room', 15 | name='type_of', 16 | field=models.CharField(choices=[('OTA', 'Open to all'), ('IO', 'Invite only')], default='OTA', max_length=3), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /backend/api/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlad99764/full-stack-react-django/2daa7139a3121539e13797c3769689ba4639c32d/backend/api/migrations/__init__.py -------------------------------------------------------------------------------- /backend/api/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth.models import AbstractUser, BaseUserManager 3 | from django.db import models 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | 7 | class UserManager(BaseUserManager): 8 | """Define a model manager for User model with no username field.""" 9 | 10 | use_in_migrations = True 11 | 12 | def _create_user(self, email, password, **extra_fields): 13 | """Create and save a User with the given email and password.""" 14 | if not email: 15 | raise ValueError("The given email must be set") 16 | email = self.normalize_email(email) 17 | user = self.model(email=email, **extra_fields) 18 | user.set_password(password) 19 | user.save(using=self._db) 20 | return user 21 | 22 | def create_user(self, email, password=None, **extra_fields): 23 | """Create and save a regular User with the given email and password.""" 24 | extra_fields.setdefault("is_staff", False) 25 | extra_fields.setdefault("is_superuser", False) 26 | return self._create_user(email, password, **extra_fields) 27 | 28 | def create_superuser(self, email, password, **extra_fields): 29 | """Create and save a SuperUser with the given email and password.""" 30 | extra_fields.setdefault("is_staff", True) 31 | extra_fields.setdefault("is_superuser", True) 32 | 33 | if extra_fields.get("is_staff") is not True: 34 | raise ValueError("Superuser must have is_staff=True.") 35 | if extra_fields.get("is_superuser") is not True: 36 | raise ValueError("Superuser must have is_superuser=True.") 37 | 38 | return self._create_user(email, password, **extra_fields) 39 | 40 | 41 | class User(AbstractUser): 42 | """User model.""" 43 | 44 | username = None 45 | email = models.EmailField(_("email address"), unique=True) 46 | 47 | USERNAME_FIELD = "email" 48 | REQUIRED_FIELDS = ["first_name", "last_name"] 49 | objects = UserManager() 50 | 51 | 52 | class Room(models.Model): 53 | 54 | """ 55 | Room Model for group calling 56 | """ 57 | 58 | ROOM_TYPE = [ 59 | ("OTA", "Open to all"), 60 | ("IO", "Invite only"), 61 | ] 62 | user = models.ForeignKey(User, on_delete=models.CASCADE) 63 | title = models.CharField(max_length=200) 64 | description = models.TextField(default="") 65 | type_of = models.CharField( 66 | max_length=3, 67 | choices=ROOM_TYPE, 68 | default="OTA", 69 | ) 70 | created_on = models.DateTimeField(auto_now_add=True) 71 | 72 | def __str__(self): 73 | return self.title 74 | -------------------------------------------------------------------------------- /backend/api/serializers.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | from django.contrib.auth.password_validation import ( 4 | validate_password as original_pwd_validate, 5 | ) 6 | from rest_framework import serializers 7 | from rest_framework_simplejwt.serializers import ( 8 | TokenObtainPairSerializer as OriginalObtainPairSerializer, 9 | ) 10 | 11 | from .models import Room, User 12 | 13 | 14 | class TokenObtainPairSerializer(OriginalObtainPairSerializer): 15 | """ 16 | Custom Token pair generator, Added full_name field to tokens to access it on a frontend 17 | """ 18 | 19 | @classmethod 20 | def get_token(cls, user): 21 | token = super().get_token(user) 22 | token["full_name"] = user.first_name + " " + user.last_name 23 | return token 24 | 25 | 26 | class RegisterTokenSerializer(serializers.ModelSerializer): 27 | """ 28 | User registers through this serializer an receive tokens for authentication 29 | """ 30 | 31 | first_name = serializers.CharField(required=True) 32 | last_name = serializers.CharField(required=True) 33 | tokens = serializers.SerializerMethodField("getting_token", read_only=True) 34 | 35 | def getting_token(self, user): 36 | refresh = TokenObtainPairSerializer.get_token(user) 37 | return { 38 | "refresh": str(refresh), 39 | "access": str(refresh.access_token), 40 | } 41 | 42 | class Meta: 43 | model = User 44 | fields = ("email", "first_name", "last_name", "password", "tokens") 45 | 46 | # Validates the password with django password validation 47 | def validate_password(self, value): 48 | original_pwd_validate(value) 49 | return value 50 | 51 | def create(self, validated_data): 52 | instance = self.Meta.model.objects.create_user(**validated_data) 53 | return instance 54 | 55 | 56 | class RoomSerializer(serializers.ModelSerializer): 57 | 58 | """ 59 | Room Serialiser 60 | """ 61 | 62 | room_id = serializers.SerializerMethodField() 63 | created_on = serializers.DateTimeField( 64 | format="%a %I:%M %p, %d %b %Y", required=False 65 | ) 66 | 67 | class Meta: 68 | model = Room 69 | fields = [ 70 | "id", 71 | "user", 72 | "title", 73 | "description", 74 | "type_of", 75 | "created_on", 76 | "room_id", 77 | ] 78 | 79 | # Generate room id 80 | def get_room_id(self, obj): 81 | if obj.type_of == "IO": 82 | return "room" + str(uuid4().hex) 83 | return "room" + str(obj.id) 84 | -------------------------------------------------------------------------------- /backend/api/tests.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.urls import reverse 4 | from rest_framework import status 5 | from rest_framework import test 6 | from rest_framework.test import APITestCase 7 | from rest_framework_simplejwt.serializers import TokenVerifySerializer 8 | 9 | from api.models import User, Room 10 | from api.serializers import RoomSerializer 11 | 12 | 13 | class UserTests(APITestCase): 14 | def setUp(self): 15 | self.test_user_data = { 16 | "email": "testuser@gmail.com", 17 | "first_name": "Test", 18 | "last_name": "User", 19 | "password": "testuser123", 20 | } 21 | self.test_user = User.objects.create_user(**self.test_user_data) 22 | 23 | def test_create_account(self): 24 | """ 25 | Ensure we can create a new User object and json response is received with jwt token pair 26 | """ 27 | # Arranging url and user data 28 | url = reverse("create_user") 29 | data = { 30 | "email": "bobphil@gmail.com", 31 | "first_name": "Bob", 32 | "last_name": "Phil", 33 | "password": "testuser123", 34 | } 35 | 36 | # Acting upon the action of posting to url 37 | response = self.client.post(url, data, format="json") 38 | 39 | # Asserts whether the user is created in database 40 | created_user = User.objects.get(email="bobphil@gmail.com") 41 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 42 | self.assertEqual( 43 | created_user.first_name, 44 | "Bob", 45 | ) 46 | self.assertEqual( 47 | created_user.last_name, 48 | "Phil", 49 | ) 50 | 51 | # Asserts 'tokens' in the response 52 | content = response.data 53 | token = content.get("tokens") 54 | self.assertIsNotNone(token) 55 | 56 | # Then Asserts refresh and access is in 'tokens' 57 | refresh = token.get("refresh") 58 | access = token.get("access") 59 | self.assertIsNotNone(refresh) 60 | self.assertIsNotNone(access) 61 | 62 | # Asserts tokens is indeed valid 63 | token_verifier = TokenVerifySerializer() 64 | self.assertEqual(token_verifier.validate({"token": refresh}), {}) 65 | self.assertEqual(token_verifier.validate({"token": access}), {}) 66 | 67 | def test_token_authentication(self): 68 | """ 69 | Checks for login functionality for token authentictation 70 | """ 71 | 72 | # Arranging url and user data 73 | url = reverse("token_obtain_pair") 74 | data = { 75 | "email": self.test_user_data["email"], 76 | "password": self.test_user_data["password"], 77 | } 78 | # Acting upon the action of posting to url 79 | response = self.client.post(url, data, format="json") 80 | self.assertEqual(response.status_code, status.HTTP_200_OK) 81 | 82 | # Then Asserts refresh and access is in 'tokens' 83 | content = response.data 84 | refresh = content.get("refresh") 85 | access = content.get("access") 86 | self.assertIsNotNone(refresh) 87 | self.assertIsNotNone(access) 88 | 89 | # Asserts tokens is indeed valid 90 | token_verifier = TokenVerifySerializer() 91 | self.assertEqual(token_verifier.validate({"token": refresh}), {}) 92 | self.assertEqual(token_verifier.validate({"token": access}), {}) 93 | 94 | 95 | class RoomTests(APITestCase): 96 | def setUp(self): 97 | 98 | # Creating user 99 | self.test_user_data = { 100 | "email": "testuser@gmail.com", 101 | "first_name": "Test", 102 | "last_name": "User", 103 | "password": "testuser123", 104 | } 105 | self.test_user = User.objects.create_user(**self.test_user_data) 106 | 107 | # Getting JWT token for the above user 108 | url = reverse("token_obtain_pair") 109 | data = { 110 | "email": self.test_user_data["email"], 111 | "password": self.test_user_data["password"], 112 | } 113 | response = self.client.post(url, data, format="json") 114 | self.access = response.data["access"] 115 | self.refresh = response.data["refresh"] 116 | 117 | # Creating two rooms 118 | self.rooms = { 119 | "room_1": { 120 | "user": self.test_user, 121 | "title": "This is test room 1", 122 | "description": "This is description 1", 123 | "type_of": "OTA", 124 | }, 125 | "room_2": { 126 | "user": self.test_user, 127 | "title": "This is test room 2", 128 | "description": "This is description 2", 129 | "type_of": "IO", 130 | }, 131 | } 132 | 133 | # Rooms are serialized 134 | self.test_room_1 = RoomSerializer( 135 | Room.objects.create(**self.rooms["room_1"]) 136 | ).data 137 | 138 | self.test_room_2 = RoomSerializer( 139 | Room.objects.create(**self.rooms["room_2"]) 140 | ).data 141 | 142 | def test_get_room_data(self): 143 | 144 | # Asserting to get rooms data 145 | url = reverse("room-list") 146 | response = self.client.get(url, format="json") 147 | self.assertEqual(response.status_code, status.HTTP_200_OK) 148 | 149 | # Confirming data actually in the response 150 | room_1 = None 151 | room_2 = None 152 | for room in response.data: 153 | if room["id"] == 1: 154 | room_1 = room 155 | elif room["id"] == 2: 156 | room_2 = room 157 | self.assertEqual(room_1["title"], self.test_room_1["title"]) 158 | self.assertEqual(room_2["title"], self.test_room_2["title"]) 159 | 160 | def test_post_room_data(self): 161 | 162 | # Arranging data for posting room 163 | url = reverse("room-list") 164 | room_data = { 165 | "user": self.test_user.id, 166 | "title": "My New Room", 167 | "description": "This is my new room", 168 | "type_of": "OTA", 169 | } 170 | 171 | # Asserting post is successful with the jwt authentication 172 | self.client.credentials(HTTP_AUTHORIZATION="Bearer " + self.access) 173 | response = self.client.post(url, room_data, format="json") 174 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 175 | self.assertEqual(response.data["room_id"], "room3") 176 | self.assertEqual(Room.objects.get(id=3).title, "My New Room") 177 | -------------------------------------------------------------------------------- /backend/api/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from rest_framework import routers 3 | from rest_framework_simplejwt.views import TokenRefreshView, TokenVerifyView 4 | 5 | from .views import RegisterAndObtainTokenView, RoomViewSet, TokenObtainPairView 6 | 7 | # Rooms url 8 | router = routers.DefaultRouter() 9 | router.register(r"rooms", RoomViewSet) 10 | urlpatterns = router.urls 11 | # Authentications Urls 12 | urlpatterns += [ 13 | path("user/create/", RegisterAndObtainTokenView.as_view(), name="create_user"), 14 | path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), 15 | path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), 16 | path("token/verify/", TokenVerifyView.as_view(), name="token_verify"), 17 | ] 18 | -------------------------------------------------------------------------------- /backend/api/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import get_object_or_404 2 | from rest_framework import status, viewsets 3 | from rest_framework.permissions import AllowAny, IsAuthenticated 4 | from rest_framework.response import Response 5 | from rest_framework.views import APIView 6 | from rest_framework_simplejwt.authentication import JWTAuthentication 7 | from rest_framework_simplejwt.views import TokenObtainPairView as OriginalObtainPairView 8 | from .models import Room 9 | from .serializers import ( 10 | RoomSerializer, 11 | TokenObtainPairSerializer, 12 | RegisterTokenSerializer, 13 | ) 14 | 15 | 16 | class TokenObtainPairView(OriginalObtainPairView): 17 | """ 18 | Replacing old 'serializer_class' with modified serializer class 19 | """ 20 | 21 | serializer_class = TokenObtainPairSerializer 22 | 23 | 24 | class RegisterAndObtainTokenView(APIView): 25 | 26 | """ 27 | Register user. Only Post method is allowed 28 | """ 29 | 30 | permission_classes = [AllowAny] 31 | authentication_classes = [] 32 | 33 | def post(self, request, format="json"): 34 | 35 | # User data is added to serializer class 36 | serializer = RegisterTokenSerializer(data=request.data) 37 | 38 | if serializer.is_valid(): 39 | user = serializer.save() 40 | if user: 41 | json = serializer.data 42 | return Response(json, status=status.HTTP_201_CREATED) 43 | return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 44 | 45 | 46 | class RoomViewSet(viewsets.ModelViewSet): 47 | """ 48 | Rooms View 49 | """ 50 | 51 | queryset = Room.objects.all().order_by("-created_on") 52 | serializer_class = RoomSerializer 53 | 54 | def get_queryset(self): 55 | 56 | # By default list of rooms return 57 | queryset = Room.objects.all().order_by("-created_on") 58 | 59 | # If search params is given then list matching the param is returned 60 | search = self.request.query_params.get("search", None) 61 | if search is not None: 62 | queryset = Room.objects.filter(title__icontains=search).order_by( 63 | "-created_on" 64 | ) 65 | return queryset 66 | 67 | def get_permissions(self): 68 | """ 69 | Instantiates and returns the list of permissions that this view requires. 70 | """ 71 | if self.action == "list" or self.action == "retrieve": 72 | permission_classes = [AllowAny] 73 | else: 74 | permission_classes = [IsAuthenticated] 75 | return [permission() for permission in permission_classes] 76 | 77 | def destroy(self, request, pk=None): 78 | 79 | """ 80 | Checks whether user requesting a delete of the room is the owner of the room or not 81 | """ 82 | room = get_object_or_404(Room, id=pk) 83 | 84 | if room: 85 | authenticate_class = JWTAuthentication() 86 | user, _ = authenticate_class.authenticate(request) 87 | if user.id == room.user.id: 88 | room.delete() 89 | else: 90 | return Response( 91 | { 92 | "message": "Either you are not logged in or you are not the owner of this room to delete" 93 | }, 94 | status=status.HTTP_401_UNAUTHORIZED, 95 | ) 96 | return Response({}, status=status.HTTP_204_NO_CONTENT) 97 | -------------------------------------------------------------------------------- /backend/group_call/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /backend/group_call/asgi.py: -------------------------------------------------------------------------------- 1 | # group_call/asgi.py 2 | import os 3 | from django.core.asgi import get_asgi_application 4 | from channels.security.websocket import AllowedHostsOriginValidator 5 | from channels.auth import AuthMiddlewareStack 6 | from channels.routing import ProtocolTypeRouter, URLRouter 7 | 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "group_call.settings") 9 | django_asgi_app = get_asgi_application() 10 | 11 | from video_signalling.routing import websocket_urlpatterns 12 | 13 | 14 | application = ProtocolTypeRouter({ 15 | "http": django_asgi_app, 16 | "websocket": AllowedHostsOriginValidator( 17 | AuthMiddlewareStack(URLRouter(websocket_urlpatterns)) 18 | ), 19 | }) 20 | 21 | -------------------------------------------------------------------------------- /backend/group_call/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for video_conference project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.1.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.1/ref/settings/ 11 | """ 12 | 13 | 14 | 15 | from pathlib import Path 16 | import os 17 | from dotenv import load_dotenv 18 | 19 | 20 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 21 | BASE_DIR = Path(__file__).resolve().parent.parent 22 | 23 | 24 | 25 | 26 | # SECURITY WARNING: don't run with debug turned on in production! 27 | DEBUG = True 28 | 29 | if DEBUG: 30 | env_path = Path('.') / 'group_call/.env' 31 | load_dotenv(dotenv_path=env_path) 32 | else: 33 | load_dotenv() 34 | SECRET_KEY = os.environ['SECRET_KEY'] 35 | 36 | 37 | 38 | if DEBUG: 39 | ALLOWED_HOSTS = ["*"] 40 | else: 41 | ALLOWED_HOSTS = ["*"] 42 | 43 | # Application definition 44 | INSTALLED_APPS = [ 45 | "daphne", 46 | "django.contrib.admin", 47 | "django.contrib.auth", 48 | "django.contrib.contenttypes", 49 | "django.contrib.sessions", 50 | "django.contrib.messages", 51 | "django.contrib.staticfiles", 52 | ] 53 | 54 | THIRD_PARTIES = ["rest_framework", "corsheaders", "channels"] 55 | PROJECT_APPS = ["api", "video_signalling"] 56 | 57 | 58 | INSTALLED_APPS += THIRD_PARTIES + PROJECT_APPS 59 | 60 | MIDDLEWARE = [ 61 | "django.middleware.security.SecurityMiddleware", 62 | "django.contrib.sessions.middleware.SessionMiddleware", 63 | # Django-cors-headers 64 | "corsheaders.middleware.CorsMiddleware", 65 | "django.middleware.common.CommonMiddleware", 66 | "django.middleware.csrf.CsrfViewMiddleware", 67 | "django.contrib.auth.middleware.AuthenticationMiddleware", 68 | "django.contrib.messages.middleware.MessageMiddleware", 69 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 70 | ] 71 | 72 | ROOT_URLCONF = "group_call.urls" 73 | 74 | TEMPLATES = [ 75 | { 76 | "BACKEND": "django.template.backends.django.DjangoTemplates", 77 | "DIRS": [], 78 | "APP_DIRS": True, 79 | "OPTIONS": { 80 | "context_processors": [ 81 | "django.template.context_processors.debug", 82 | "django.template.context_processors.request", 83 | "django.contrib.auth.context_processors.auth", 84 | "django.contrib.messages.context_processors.messages", 85 | ] 86 | }, 87 | } 88 | ] 89 | 90 | WSGI_APPLICATION = "group_call.wsgi.application" 91 | 92 | 93 | # Database 94 | if DEBUG: 95 | database = { 96 | "ENGINE": "django.db.backends.sqlite3", 97 | "NAME": BASE_DIR / "db.sqlite3", 98 | } 99 | else: 100 | database = { 101 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 102 | 'NAME': os.environ['DATABASE_NAME'], 103 | 'USER': os.environ['DATABASE_USER'], 104 | 'PASSWORD': os.environ['DATABASE_PASSWORD'], 105 | 'HOST': os.environ['DATABASE_HOST'], 106 | 'PORT': os.environ['DATABASE_PORT'], 107 | } 108 | 109 | 110 | DATABASES = { 111 | "default": database 112 | } 113 | 114 | 115 | # Password validation 116 | AUTH_PASSWORD_VALIDATORS = [ 117 | { 118 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" 119 | }, 120 | {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, 121 | {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, 122 | {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, 123 | ] 124 | 125 | 126 | 127 | LANGUAGE_CODE = "en-us" 128 | 129 | TIME_ZONE = "UTC" 130 | 131 | USE_I18N = True 132 | 133 | USE_L10N = True 134 | 135 | USE_TZ = True 136 | 137 | 138 | 139 | STATIC_URL = "/static/" 140 | 141 | # ADDED CUSTOM CONFIGURATION 142 | if DEBUG: 143 | CORS_ALLOW_ALL_ORIGINS = True 144 | else: 145 | CORS_ALLOW_ALL_ORIGINS = True 146 | 147 | REST_FRAMEWORK = { 148 | "DEFAULT_AUTHENTICATION_CLASSES": [ 149 | "rest_framework_simplejwt.authentication.JWTAuthentication" 150 | ], 151 | "DEFAULT_RENDERER_CLASSES": ( 152 | "djangorestframework_camel_case.render.CamelCaseJSONRenderer", 153 | "djangorestframework_camel_case.render.CamelCaseBrowsableAPIRenderer", 154 | ), 155 | "DEFAULT_PARSER_CLASSES": ( 156 | "djangorestframework_camel_case.parser.CamelCaseFormParser", 157 | "djangorestframework_camel_case.parser.CamelCaseMultiPartParser", 158 | "djangorestframework_camel_case.parser.CamelCaseJSONParser", 159 | ), 160 | "JSON_UNDERSCOREIZE": {"no_underscore_before_number": True}, 161 | } 162 | 163 | # Custom User Model 164 | AUTH_USER_MODEL = "api.User" 165 | 166 | # Pointing to channels to routing configurations 167 | ASGI_APPLICATION = "group_call.asgi.application" 168 | 169 | # Assigning in memory channel layer 170 | # if DEBUG: 171 | channel_layer = {"BACKEND": "channels.layers.InMemoryChannelLayer"} 172 | # else: 173 | # channel_layer = { 174 | # "BACKEND": "channels_redis.core.RedisChannelLayer", 175 | # "CONFIG": { 176 | # "hosts": [f"redis://:{os.environ['REDIS_PASSWORD']}@{os.environ['REDIS_URL']}"], 177 | # }, 178 | # }, 179 | 180 | 181 | CHANNEL_LAYERS = {"default": channel_layer} 182 | -------------------------------------------------------------------------------- /backend/group_call/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.conf.urls.static import static 3 | from django.contrib import admin 4 | from django.urls import include, path 5 | from django.views.generic import TemplateView 6 | 7 | urlpatterns = [ 8 | path("admin/", admin.site.urls), 9 | path("api/", include("api.urls")), 10 | ] 11 | -------------------------------------------------------------------------------- /backend/group_call/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for video_conference project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "group_call.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /backend/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "group_call.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /backend/requirements.txt: -------------------------------------------------------------------------------- 1 | asgiref==3.8.1 2 | attrs==23.2.0 3 | autobahn==23.6.2 4 | Automat==22.10.0 5 | cffi==1.16.0 6 | channels==4.1.0 7 | constantly==23.10.4 8 | cryptography==42.0.7 9 | daphne==4.1.2 10 | Django==5.0.6 11 | django-cors-headers==4.3.1 12 | djangorestframework==3.15.1 13 | djangorestframework-camel-case==1.4.2 14 | djangorestframework-simplejwt==5.3.1 15 | hyperlink==21.0.0 16 | idna==3.7 17 | incremental==22.10.0 18 | pip==23.2.1 19 | pyasn1==0.6.0 20 | pyasn1_modules==0.4.0 21 | pycparser==2.22 22 | PyJWT==2.8.0 23 | pyOpenSSL==24.1.0 24 | python-dotenv==1.0.1 25 | service-identity==24.1.0 26 | setuptools==69.5.1 27 | six==1.16.0 28 | sqlparse==0.5.0 29 | Twisted==24.3.0 30 | twisted-iocpsupport==1.0.4 31 | txaio==23.1.1 32 | typing_extensions==4.11.0 33 | tzdata==2024.1 34 | zope.interface==6.3 -------------------------------------------------------------------------------- /backend/video_signalling/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlad99764/full-stack-react-django/2daa7139a3121539e13797c3769689ba4639c32d/backend/video_signalling/__init__.py -------------------------------------------------------------------------------- /backend/video_signalling/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class VideoSignallingConfig(AppConfig): 5 | name = 'video_signalling' 6 | -------------------------------------------------------------------------------- /backend/video_signalling/consumers.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from channels.generic.websocket import AsyncWebsocketConsumer 4 | from rest_framework_simplejwt.authentication import JWTAuthentication 5 | 6 | 7 | class VideoConsumer(AsyncWebsocketConsumer): 8 | 9 | # Users stored here temporarily 10 | USERS_CONNECTED = [] 11 | 12 | async def connect(self): 13 | 14 | # When user connects user is added to the respective room name 15 | self.room_name = self.scope["url_route"]["kwargs"]["room_name"] 16 | self.room_group_name = "room_%s" % self.room_name 17 | await (self.channel_layer.group_add)(self.room_group_name, self.channel_name) 18 | await self.accept() 19 | 20 | async def disconnect(self, close_code): 21 | 22 | # Firing signals to other user about user who just disconneted 23 | await self.channel_layer.group_send( 24 | self.room_group_name, 25 | { 26 | "type": "disconnected", 27 | "data": {"from": self.user_id}, 28 | }, 29 | ) 30 | 31 | # User data is cleared and discarded from the room 32 | user = self.find_user(self.user_id) 33 | self.USERS_CONNECTED.remove(user) 34 | await (self.channel_layer.group_discard)( 35 | self.room_group_name, self.channel_name 36 | ) 37 | 38 | async def receive(self, text_data): 39 | data = json.loads(text_data) 40 | 41 | # Checks user is valide user or not and added to USER_CONNECTED 42 | if data["type"] == "new_user_joined": 43 | 44 | jwt_authenticate = JWTAuthentication() 45 | try: 46 | jwt_authenticate.get_validated_token(data["token"]) 47 | except: 48 | self.close() 49 | self.user_id = data["from"] 50 | self.USERS_CONNECTED.append( 51 | {"user_id": data["from"], "user_full_name": data["user_full_name"]} 52 | ) 53 | data["users_connected"] = self.USERS_CONNECTED 54 | 55 | # All the users is notified about new user joining 56 | await self.channel_layer.group_send( 57 | self.room_group_name, 58 | { 59 | "type": "new_user_joined", 60 | "data": data, 61 | }, 62 | ) 63 | 64 | # Offer from the user is send back to other users in the room 65 | elif data["type"] == "sending_offer": 66 | await self.channel_layer.group_send( 67 | self.room_group_name, 68 | { 69 | "type": "sending_offer", 70 | "data": data, 71 | }, 72 | ) 73 | 74 | # Answer from the user is send back to user who sent the offer 75 | elif data["type"] == "sending_answer": 76 | await self.channel_layer.group_send( 77 | self.room_group_name, 78 | { 79 | "type": "sending_answer", 80 | "data": data, 81 | }, 82 | ) 83 | 84 | # Firing signals to other user about user who just disconneted 85 | elif data["type"] == "disconnected": 86 | await self.channel_layer.group_send( 87 | self.room_group_name, 88 | { 89 | "type": "disconnected", 90 | "data": data, 91 | }, 92 | ) 93 | 94 | # FUNCTIONS FOR THE GROUP SEND METHOD ABOVE... 95 | async def new_user_joined(self, event): 96 | data = event["data"] 97 | await self.send( 98 | json.dumps( 99 | { 100 | "type": "new_user_joined", 101 | "from": data["from"], 102 | "users_connected": data["users_connected"], 103 | } 104 | ) 105 | ) 106 | 107 | async def sending_offer(self, event): 108 | data = event["data"] 109 | await self.send( 110 | json.dumps( 111 | { 112 | "type": "sending_offer", 113 | "from": data["from"], 114 | "to": data["to"], 115 | "offer": data["offer"], 116 | } 117 | ) 118 | ) 119 | 120 | async def sending_answer(self, event): 121 | data = event["data"] 122 | await self.send( 123 | json.dumps( 124 | { 125 | "type": "sending_answer", 126 | "from": data["from"], 127 | "to": data["to"], 128 | "answer": data["answer"], 129 | } 130 | ) 131 | ) 132 | 133 | async def disconnected(self, event): 134 | 135 | data = event["data"] 136 | await self.send( 137 | json.dumps( 138 | { 139 | "type": "disconnected", 140 | "from": data["from"], 141 | } 142 | ) 143 | ) 144 | 145 | # Method to find user from USER_CONNECTED 146 | def find_user(self, user_id): 147 | for user in self.USERS_CONNECTED: 148 | if user["user_id"] == user_id: 149 | return user 150 | 151 | return None 152 | 153 | 154 | -------------------------------------------------------------------------------- /backend/video_signalling/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlad99764/full-stack-react-django/2daa7139a3121539e13797c3769689ba4639c32d/backend/video_signalling/migrations/__init__.py -------------------------------------------------------------------------------- /backend/video_signalling/routing.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | 3 | from . import consumers 4 | 5 | # Video room route 6 | websocket_urlpatterns = [ 7 | re_path(r"video/(?P\w+)/$", consumers.VideoConsumer.as_asgi()), 8 | ] -------------------------------------------------------------------------------- /backend/video_signalling/tests.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlad99764/full-stack-react-django/2daa7139a3121539e13797c3769689ba4639c32d/backend/video_signalling/tests.py -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "dependencies": { 7 | "@material-ui/core": "^4.12.4", 8 | "@material-ui/icons": "^4.11.3", 9 | "@material-ui/lab": "^4.0.0-alpha.61", 10 | "@testing-library/jest-dom": "^5.11.4", 11 | "@testing-library/react": "^11.1.0", 12 | "@testing-library/user-event": "^12.1.10", 13 | "axios": "^0.21.0", 14 | "fontsource-roboto": "^3.0.3", 15 | "formik": "^2.2.1", 16 | "jwt-decode": "^3.1.2", 17 | "react": "^17.0.1", 18 | "react-dom": "^17.0.1", 19 | "react-router-dom": "^5.2.0", 20 | "react-scripts": "4.0.0", 21 | "simple-peer": "^9.9.3", 22 | "yup": "^0.29.3" 23 | }, 24 | "scripts": { 25 | "start": "react-scripts start", 26 | "build": "react-scripts build", 27 | "test": "react-scripts test", 28 | "eject": "react-scripts eject" 29 | }, 30 | "eslintConfig": { 31 | "extends": [ 32 | "react-app", 33 | "react-app/jest" 34 | ] 35 | }, 36 | "browserslist": { 37 | "production": [ 38 | ">0.2%", 39 | "not dead", 40 | "not op_mini all" 41 | ], 42 | "development": [ 43 | "last 1 chrome version", 44 | "last 1 firefox version", 45 | "last 1 safari version" 46 | ] 47 | }, 48 | "devDependencies": {} 49 | } 50 | -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 16 | 17 | 26 | Group Call 27 | 28 | 29 | 30 |
31 |
32 | 33 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /frontend/src/App.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import "fontsource-roboto"; 4 | import Routes from "./Routes"; 5 | import Container from "@material-ui/core/Container"; 6 | 7 | class App extends React.Component { 8 | render() { 9 | return ( 10 | 11 | 12 | 13 | ); 14 | } 15 | } 16 | 17 | export default App; 18 | -------------------------------------------------------------------------------- /frontend/src/Routes.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { HashRouter as Router, Switch } from "react-router-dom"; 3 | import jwt_decode from "jwt-decode"; 4 | 5 | // Components 6 | import { 7 | Login, 8 | Register, 9 | AuthenticationRoute, 10 | LobbyRoute, 11 | NavigationBar, 12 | VideoRoomRoute, 13 | } from "./components"; 14 | 15 | // Utility components, functions, constants, objects... 16 | import { 17 | Feedback, 18 | UserInfoProvider, 19 | AVAILABLE_PATHS, 20 | axiosInstance, 21 | validateToken, 22 | refreshingAccessToken, 23 | getRoomsList, 24 | Loading, 25 | } from "./components/utilities"; 26 | 27 | export class Routes extends Component { 28 | constructor(props) { 29 | super(props); 30 | 31 | this.state = { 32 | // User information 33 | userData: { 34 | isDataArrived: false, 35 | userId: null, 36 | userFullName: "", 37 | isUserLoggedIn: false, 38 | }, 39 | isRoomFormOpen: false, 40 | 41 | // For Feedback method 42 | severity: "", 43 | feedbackMsg: "", 44 | isFeedbackOpen: false, 45 | 46 | // Related to room list 47 | roomListData: [], 48 | loadingRooms: true, 49 | search: "", 50 | }; 51 | 52 | // Loading list of rooms 53 | this.loadRooms = this.loadRooms.bind(this); 54 | this.handleSearchChanges = this.handleSearchChanges.bind(this); 55 | 56 | // Opening and closing of form 57 | this.closeRoomForm = this.closeRoomForm.bind(this); 58 | this.openRoomForm = this.openRoomForm.bind(this); 59 | 60 | // Feedback messages 61 | this.printFeedback = this.printFeedback.bind(this); 62 | this.closeFeedback = this.closeFeedback.bind(this); 63 | 64 | // User authentication and refreshing access token 65 | this.authenticateUser = this.authenticateUser.bind(this); 66 | } 67 | 68 | // Method to load all the rooms 69 | loadRooms = (search = "") => { 70 | this.setState({ 71 | loadingRooms: true, 72 | }); 73 | getRoomsList(axiosInstance, search) 74 | .then((res) => { 75 | const access_token = localStorage.getItem("access_token"); 76 | axiosInstance.defaults.headers["Authorization"] = 77 | "Bearer " + access_token; 78 | this.setState(() => ({ roomListData: res.data, loadingRooms: false })); 79 | }) 80 | .catch((error) => { 81 | this.setState(() => ({ loadingRooms: false })); 82 | this.printFeedback({ type: "error", feedbackMsg: error.message }); 83 | console.log(error.message); 84 | }); 85 | }; 86 | 87 | // Method runs whenever search is used 88 | handleSearchChanges = async (event) => { 89 | await this.setState({ 90 | search: event.target.value, 91 | }); 92 | await this.loadRooms(this.state.search); 93 | }; 94 | 95 | closeRoomForm() { 96 | this.setState({ 97 | isRoomFormOpen: false, 98 | }); 99 | } 100 | openRoomForm() { 101 | this.setState({ 102 | isRoomFormOpen: true, 103 | }); 104 | } 105 | 106 | // Method for printing feedback to user 107 | printFeedback = ({ type, feedbackMsg }) => { 108 | switch (type) { 109 | case "success": 110 | this.setState({ 111 | severity: "success", 112 | feedbackMsg: feedbackMsg, 113 | isFeedbackOpen: true, 114 | }); 115 | break; 116 | case "error": 117 | this.setState({ 118 | severity: "error", 119 | feedbackMsg: feedbackMsg, 120 | isFeedbackOpen: true, 121 | }); 122 | break; 123 | default: 124 | break; 125 | } 126 | }; 127 | 128 | // Closing feedback message 129 | closeFeedback = (event, reason) => { 130 | if (reason === "clickaway") { 131 | return; 132 | } 133 | 134 | this.setState({ 135 | isFeedbackOpen: false, 136 | }); 137 | }; 138 | 139 | // Checks whether is logged in or not 140 | // Depending upon that userData is populated 141 | authenticateUser = () => { 142 | const refresh_token = localStorage.getItem("refresh_token"); 143 | 144 | // Checks the refresh token is valid which then determines whether user is logged-in or not 145 | validateToken(axiosInstance, refresh_token) 146 | .then((response) => { 147 | if (response.status === 200) { 148 | const userId = jwt_decode(refresh_token).user_id; 149 | const userFullName = jwt_decode(refresh_token).full_name; 150 | this.setState({ 151 | userData: { 152 | isDataArrived: true, 153 | userId: userId, 154 | userFullName: userFullName, 155 | isUserLoggedIn: true, 156 | }, 157 | }); 158 | 159 | // If the refresh token is valid then access token is refreshed 160 | refreshingAccessToken(); 161 | } 162 | }) 163 | .catch((error) => { 164 | console.log(error.message); 165 | localStorage.removeItem("access_token"); 166 | localStorage.removeItem("refresh_token"); 167 | this.setState({ 168 | userData: { 169 | isDataArrived: true, 170 | userId: null, 171 | userFullName: "", 172 | isUserLoggedIn: false, 173 | }, 174 | }); 175 | }); 176 | }; 177 | 178 | componentDidMount = () => { 179 | // checks authentication when components mounts 180 | this.authenticateUser(); 181 | }; 182 | render() { 183 | const { 184 | LOBBY_PATH, 185 | LOGIN_PATH, 186 | REGISTER_PATH, 187 | VIDEO_ROOM_PATH, 188 | } = AVAILABLE_PATHS; 189 | const { 190 | userData, 191 | isRoomFormOpen, 192 | roomListData, 193 | loadingRooms, 194 | search, 195 | severity, 196 | feedbackMsg, 197 | isFeedbackOpen, 198 | } = this.state; 199 | 200 | const authenticationProps = { 201 | isUserLoggedIn: userData.isUserLoggedIn, 202 | printFeedback: this.printFeedback, 203 | authenticateUser: this.authenticateUser, 204 | }; 205 | const lobbyProps = { 206 | userData: userData, 207 | loadingRooms: loadingRooms, 208 | roomListData: roomListData, 209 | loadRooms: this.loadRooms, 210 | printFeedback: this.printFeedback, 211 | closeRoomForm: this.closeRoomForm, 212 | isRoomFormOpen: isRoomFormOpen, 213 | }; 214 | const navProps = { 215 | search: search, 216 | handleSearchChanges: this.handleSearchChanges, 217 | authenticateUser: this.authenticateUser, 218 | openRoomForm: this.openRoomForm, 219 | printFeedback: this.printFeedback, 220 | }; 221 | 222 | return userData.isDataArrived ? ( 223 | 224 | 225 | {/* Nav */} 226 | 227 | 233 | 234 | {/* Lobby */} 235 | 236 | 237 | {/* Login */} 238 | 244 | {/* Register */} 245 | 251 | 252 | {/* Video Room */} 253 | 259 | 260 | 261 | 262 | ) : ( 263 | 264 | ); 265 | } 266 | } 267 | 268 | export default Routes; 269 | -------------------------------------------------------------------------------- /frontend/src/components/authentication/AuthenticationRoute.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Route, Redirect } from "react-router-dom"; 3 | 4 | import { AVAILABLE_PATHS } from "../utilities/CONSTANTS"; 5 | 6 | // Custom Route for redirecting user to home if they are already authenticated 7 | function AuthenticationRoute(props) { 8 | const { component: Component, authenticationProps, ...restOfProps } = props; 9 | return ( 10 | 13 | !authenticationProps.isUserLoggedIn ? ( 14 | 19 | ) : ( 20 | 21 | ) 22 | } 23 | /> 24 | ); 25 | } 26 | 27 | export default AuthenticationRoute; 28 | -------------------------------------------------------------------------------- /frontend/src/components/authentication/Login.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { Formik, Form } from "formik"; 3 | 4 | // Material UI components 5 | import FormHelperText from "@material-ui/core/FormHelperText"; 6 | import Button from "@material-ui/core/Button"; 7 | import Paper from "@material-ui/core/Paper"; 8 | import Typography from "@material-ui/core/Typography"; 9 | import { withStyles } from "@material-ui/core/styles"; 10 | 11 | // Utility components, functions, constants, objects... 12 | import { 13 | FormikUIField, 14 | loginValidationSchema, 15 | axiosInstance, 16 | RouterUILink, 17 | } from "../utilities"; 18 | import formStyles from "./form_styles"; 19 | 20 | class Login extends Component { 21 | constructor(props) { 22 | super(props); 23 | this.state = { 24 | serverErrors: "", 25 | }; 26 | this.onSubmitLoginForm = this.onSubmitLoginForm.bind(this); 27 | } 28 | 29 | // Submission form 30 | onSubmitLoginForm(data) { 31 | const userData = { 32 | email: data.email, 33 | password: data.password, 34 | }; 35 | const { 36 | history, 37 | redirectPath, 38 | authenticateUser, 39 | printFeedback, 40 | } = this.props; 41 | 42 | // Sends post requests 43 | axiosInstance 44 | .post("token/", userData) 45 | .then(({ data }) => { 46 | // Tokens are added to headers upcoming requests 47 | // And they stored in local storage 48 | axiosInstance.defaults.headers["Authorization"] = 49 | "Bearer " + data.access; 50 | localStorage.setItem("access_token", data.access); 51 | localStorage.setItem("refresh_token", data.refresh); 52 | 53 | // User is then authenticated and redirected to lobby with print feedback message 54 | authenticateUser(); 55 | history.push(redirectPath); 56 | printFeedback({ type: "success", feedbackMsg: "You are logged in" }); 57 | }) 58 | .catch((error) => { 59 | console.log(error.message); 60 | 61 | // Server error is set to state to display down in component 62 | if (error.response) { 63 | this.setState({ 64 | serverErrors: Object.values(error.response.data), 65 | }); 66 | } 67 | }); 68 | } 69 | 70 | render() { 71 | const { classes } = this.props; 72 | let initialValues = { 73 | email: "", 74 | password: "", 75 | }; 76 | return ( 77 | 78 | 83 | {({ dirty, isValid, errors, touched }) => ( 84 |
85 | 86 | Login 87 | 88 | 89 | {/* Email */} 90 | 98 | 99 | {/* Password */} 100 | 108 | 109 | {/* Server side error */} 110 | {this.state.serverErrors 111 | ? this.state.serverErrors.map((error, index) => ( 112 | 113 | {error} 114 | 115 | )) 116 | : null} 117 | 118 | {/* Login Button */} 119 | 129 | 130 | {/* Link to registor page */} 131 | 132 | not a member? 133 | 134 | 135 | 136 | )} 137 |
138 |
139 | ); 140 | } 141 | } 142 | 143 | export default withStyles(formStyles)(Login); 144 | -------------------------------------------------------------------------------- /frontend/src/components/authentication/Register.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { Formik, Form } from "formik"; 3 | 4 | // Material UI components 5 | import FormHelperText from "@material-ui/core/FormHelperText"; 6 | import Button from "@material-ui/core/Button"; 7 | import Paper from "@material-ui/core/Paper"; 8 | import Typography from "@material-ui/core/Typography"; 9 | import { withStyles } from "@material-ui/core/styles"; 10 | 11 | // Utility components, functions, constants, objects... 12 | import { 13 | FormikUIField, 14 | registerValidationSchema, 15 | axiosInstance, 16 | RouterUILink, 17 | } from "../utilities"; 18 | import formStyles from "./form_styles"; 19 | 20 | const passwordHelperText = [ 21 | "Your password can’t be too similar to your other personal information.", 22 | "Your password must contain at least 8 characters.", 23 | "Your password can’t be a commonly used password. ", 24 | "Your password can’t be entirely numeric.", 25 | ]; 26 | 27 | class Register extends Component { 28 | constructor(props) { 29 | super(props); 30 | this.state = { 31 | serverErrors: "", 32 | }; 33 | this.onSubmitRegisterForm = this.onSubmitRegisterForm.bind(this); 34 | } 35 | 36 | // Submission form 37 | onSubmitRegisterForm(data) { 38 | const userData = { 39 | email: data.email, 40 | firstName: data.firstName, 41 | lastName: data.lastName, 42 | password: data.password, 43 | }; 44 | const { 45 | history, 46 | redirectPath, 47 | authenticateUser, 48 | printFeedback, 49 | } = this.props; 50 | 51 | // Sends post requests 52 | axiosInstance 53 | .post("user/create/", userData) 54 | .then(({ data: { tokens } }) => { 55 | // Tokens are added to headers upcoming requests 56 | // And they stored in local storage 57 | axiosInstance.defaults.headers["Authorization"] = 58 | "Bearer " + tokens.access; 59 | localStorage.setItem("access_token", tokens.access); 60 | localStorage.setItem("refresh_token", tokens.refresh); 61 | 62 | // User is then authenticated and redirected to lobby with print feedback message 63 | authenticateUser(); 64 | history.push(redirectPath); 65 | printFeedback({ 66 | type: "success", 67 | feedbackMsg: "You are registered and logged in", 68 | }); 69 | }) 70 | .catch((error) => { 71 | console.log(error.message); 72 | 73 | // Server error is set to state to display down in component 74 | if (error.response) { 75 | this.setState({ 76 | serverErrors: Object.values(error.response.data), 77 | }); 78 | } 79 | }); 80 | } 81 | 82 | render() { 83 | const { classes } = this.props; 84 | let initialValues = { 85 | firstName: "", 86 | lastName: "", 87 | email: "", 88 | password: "", 89 | confirmation: "", 90 | }; 91 | return ( 92 | 93 | 98 | {({ dirty, isValid, errors, touched }) => ( 99 |
100 | 101 | Register 102 | 103 | 104 |
105 | {/* First Name */} 106 | 114 | 115 | {/* Last Name */} 116 | 124 |
125 | 126 | {/* Email */} 127 | 135 | 143 | {passwordHelperText.map((text, index) => ( 144 | {text} 145 | ))} 146 | 147 | {/* Password */} 148 | 156 | 157 | Enter the same password as before, for verification. 158 | 159 | {/* Server Errors */} 160 | {this.state.serverErrors 161 | ? this.state.serverErrors.map((error, index) => ( 162 | 163 | {error} 164 | 165 | )) 166 | : null} 167 | 168 | {/* Register Button */} 169 | 179 | 180 | {/* Link to Login page */} 181 | 182 | already have an account? 183 | 184 | 185 | 186 | )} 187 |
188 |
189 | ); 190 | } 191 | } 192 | 193 | export default withStyles(formStyles)(Register); 194 | -------------------------------------------------------------------------------- /frontend/src/components/authentication/form_styles.jsx: -------------------------------------------------------------------------------- 1 | // Custom styling for authentication forms 2 | const formStyles = (theme) => ({ 3 | formPaper: { 4 | margin: "2rem auto", 5 | width: "80%", 6 | padding: theme.spacing(3), 7 | textAlign: "center", 8 | borderRadius: "1rem", 9 | [theme.breakpoints.up("md")]: { margin: "4rem auto", width: "40%" }, 10 | }, 11 | fullName: { 12 | display: "flex", 13 | "flex-direction": "column", 14 | "justify-content": "space-between", 15 | [theme.breakpoints.up("md")]: { 16 | "flex-direction": "row", 17 | "justify-content": "space-evenly", 18 | }, 19 | }, 20 | formButton: { 21 | margin: "1.5rem 0", 22 | color: "#fff", 23 | "background-image": 24 | "linear-gradient(to right, rgba(46,49,146,1) 13%, rgba(27,255,255,1) 96%)", 25 | 26 | transition: "0.5s", 27 | "background-size": " 200% auto", 28 | 29 | "&:hover": { 30 | "background-position": "right center", 31 | }, 32 | "&:disabled": { 33 | background: "#829baf", 34 | color: "#e3f2fd", 35 | }, 36 | }, 37 | }); 38 | export default formStyles; 39 | -------------------------------------------------------------------------------- /frontend/src/components/authentication/index.jsx: -------------------------------------------------------------------------------- 1 | import AuthenticationRoute from "./AuthenticationRoute"; 2 | import Login from "./Login"; 3 | import Register from "./Register"; 4 | 5 | export { AuthenticationRoute, Login, Register }; 6 | -------------------------------------------------------------------------------- /frontend/src/components/index.jsx: -------------------------------------------------------------------------------- 1 | import { Login, Register, AuthenticationRoute } from "./authentication/"; 2 | import LobbyRoute from "./lobby/LobbyRoute"; 3 | import NavigationBar from "./navigation_bar/NavigationBar"; 4 | import VideoRoomRoute from "./video_room/VideoRoomRoute"; 5 | 6 | export { 7 | Login, 8 | Register, 9 | AuthenticationRoute, 10 | LobbyRoute, 11 | NavigationBar, 12 | VideoRoomRoute, 13 | }; 14 | -------------------------------------------------------------------------------- /frontend/src/components/lobby/LobbyRoute.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Route } from "react-router-dom"; 3 | 4 | import RoomList from "./RoomList"; 5 | 6 | // Custom route component to pass extra props to RoomList component 7 | function LobbyRoute(props) { 8 | const { lobbyProps, ...restOfProps } = props; 9 | return ( 10 | } 13 | /> 14 | ); 15 | } 16 | 17 | export default LobbyRoute; 18 | -------------------------------------------------------------------------------- /frontend/src/components/lobby/Room.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | // Material UI components 4 | import Typography from "@material-ui/core/Typography"; 5 | import TextField from "@material-ui/core/TextField"; 6 | import Button from "@material-ui/core/Button"; 7 | import ButtonGroup from "@material-ui/core/ButtonGroup"; 8 | import Accordion from "@material-ui/core/Accordion"; 9 | import AccordionSummary from "@material-ui/core/AccordionSummary"; 10 | import AccordionDetails from "@material-ui/core/AccordionDetails"; 11 | import ExpandMoreIcon from "@material-ui/icons/ExpandMore"; 12 | import { withStyles } from "@material-ui/core/styles"; 13 | 14 | // Utility components, functions, constants, objects... 15 | import { UserInfoContext } from "../utilities/components/UserInfoProvider"; 16 | import roomStyles from "./room_styles"; 17 | 18 | class Room extends Component { 19 | constructor(props) { 20 | super(props); 21 | 22 | this.renderRoomType = this.renderRoomType.bind(this); 23 | } 24 | 25 | // User information 26 | static contextType = UserInfoContext; 27 | 28 | // Render room types based on initial acquired from database 29 | renderRoomType(roomTypeKeyWord) { 30 | switch (roomTypeKeyWord) { 31 | case "OTA": 32 | return "Open to All"; 33 | case "IO": 34 | return "Invite only"; 35 | default: 36 | return "unrecognized room type"; 37 | } 38 | } 39 | 40 | // Copy room url to clipboard 41 | copyRoomUrl = (databaseId) => { 42 | const roomInput = document.getElementById(`room-data-base${databaseId}`); 43 | roomInput.select(); 44 | document.execCommand("copy"); 45 | this.props.printFeedback({ type: "success", feedbackMsg: "Link copied" }); 46 | }; 47 | 48 | render() { 49 | const { 50 | apiData: { id, title, description, typeOf, createdOn, roomId, user }, 51 | classes, 52 | deleteRoom, 53 | enterRoom, 54 | } = this.props; 55 | const { userId } = this.context; 56 | return ( 57 | 58 | {/* Head */} 59 | }> 60 | {title} 61 | 62 | {this.renderRoomType(typeOf)} 63 | 64 | 65 | 66 | {/* Description */} 67 | 68 |
69 |
70 | 75 | {title} 76 | 77 | 78 | created on {createdOn} 79 | 80 | {description} 81 |
82 | 83 |
84 | 90 | {/* Delete button is only shown if the room belongs to current user */} 91 | {userId === user ? ( 92 | 99 | ) : null} 100 | 101 | {/* 'Invite Only' type have there enter button not shown */} 102 | {typeOf !== "IO" ? ( 103 | 110 | ) : userId === user ? ( 111 | 118 | ) : null} 119 | 120 |
121 | 122 | {/* Copy Room Url */} 123 |
124 | {userId === user && typeOf === "IO" ? ( 125 | <> 126 | 133 | 142 | 143 | ) : null} 144 |
145 |
146 |
147 |
148 | ); 149 | } 150 | } 151 | export default withStyles(roomStyles)(Room); 152 | -------------------------------------------------------------------------------- /frontend/src/components/lobby/RoomForm/CreateRoomForm.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { Formik, Form } from "formik"; 3 | 4 | // Material UI components 5 | import Button from "@material-ui/core/Button"; 6 | import Typography from "@material-ui/core/Typography"; 7 | import Paper from "@material-ui/core/Paper"; 8 | import { withStyles } from "@material-ui/core/styles"; 9 | 10 | // Utility components, functions, constants, objects... 11 | import { 12 | roomFormValidationSchema, 13 | FormikUIField, 14 | FormikUISelect, 15 | UserInfoContext, 16 | } from "../../utilities"; 17 | import createRoomFormStyles from "./create_room_form_styles"; 18 | 19 | class CreateRoomForm extends Component { 20 | constructor(props) { 21 | super(props); 22 | 23 | this.roomTypes = [ 24 | { 25 | value: "OTA", 26 | label: "Open to all", 27 | }, 28 | { 29 | value: "IO", 30 | label: "Invite only", 31 | }, 32 | ]; 33 | } 34 | static contextType = UserInfoContext; 35 | render() { 36 | const { userId } = this.context; 37 | 38 | // Instantiating form fields with pretty much empty values 39 | let initialValues = { 40 | user: userId, 41 | title: "", 42 | description: "", 43 | typeOf: "OTA", 44 | }; 45 | 46 | const { classes, onRoomFormSubmit } = this.props; 47 | 48 | return ( 49 | 50 | 55 | {({ isValid, dirty, errors, touched }) => ( 56 |
57 | 58 | New Room 59 | 60 | 61 | {/* Title */} 62 | 70 | 71 | {/* Description */} 72 | 81 | 82 | {/* Room type */} 83 | 90 | 91 | 100 | 101 | )} 102 |
103 |
104 | ); 105 | } 106 | } 107 | export default withStyles(createRoomFormStyles)(CreateRoomForm); 108 | -------------------------------------------------------------------------------- /frontend/src/components/lobby/RoomForm/create_room_form_styles.jsx: -------------------------------------------------------------------------------- 1 | // Custom form styles 2 | const createRoomFormStyles = (theme) => ({ 3 | formPaper: { 4 | padding: "2rem", 5 | position: "absolute", 6 | top: "50%", 7 | left: "50%", 8 | transform: "translate(-50%, -50%)", 9 | width: "80%", 10 | [theme.breakpoints.up("md")]: { width: "40%" }, 11 | }, 12 | createRoomBtn: { 13 | margin: "1rem 0", 14 | color: "#fff", 15 | "background-image": 16 | "linear-gradient(to right, rgba(46,49,146,1) 13%, rgba(27,255,255,1) 96%)", 17 | 18 | transition: "0.5s", 19 | "background-size": " 200% auto", 20 | 21 | "&:hover": { 22 | "background-position": "right center", 23 | }, 24 | "&:disabled": { 25 | background: "#829baf", 26 | color: "#e3f2fd", 27 | }, 28 | }, 29 | }); 30 | export default createRoomFormStyles; 31 | -------------------------------------------------------------------------------- /frontend/src/components/lobby/RoomList.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | // Material UI components 4 | import Modal from "@material-ui/core/Modal"; 5 | import Alert from "@material-ui/lab/Alert"; 6 | import Typography from "@material-ui/core/Typography"; 7 | 8 | // Components 9 | import Room from "./Room"; 10 | import CreateRoomForm from "./RoomForm/CreateRoomForm"; 11 | 12 | // Utility components, functions, constants, objects... 13 | import { 14 | axiosInstance, 15 | Loading, 16 | refreshingAccessToken, 17 | UserInfoContext, RouterUILink 18 | } from "../utilities/"; 19 | 20 | class RoomList extends Component { 21 | constructor(props) { 22 | super(props); 23 | 24 | this.deleteRoom = this.deleteRoom.bind(this); 25 | this.enterRoom = this.enterRoom.bind(this); 26 | this.onRoomFormSubmit = this.onRoomFormSubmit.bind(this); 27 | } 28 | // User information 29 | static contextType = UserInfoContext; 30 | 31 | // Creating Room form 32 | onRoomFormSubmit = async (data, { resetForm }) => { 33 | const roomData = { 34 | user: data.user, 35 | title: data.title, 36 | description: data.description, 37 | typeOf: data.typeOf, 38 | }; 39 | const { printFeedback, closeRoomForm } = this.props; 40 | 41 | // First refreshes JWT access token stored in local storage if access token is invalid 42 | await refreshingAccessToken(); 43 | 44 | // Then posts the data to backend with the valid access token in the header 45 | await axiosInstance 46 | .post("rooms/", roomData) 47 | .then(() => { 48 | // After successfully posting form these tasks are performed in order 49 | resetForm(); 50 | closeRoomForm(); 51 | printFeedback({ type: "success", feedbackMsg: "Room created" }); 52 | this.props.loadRooms(); 53 | }) 54 | .catch((error) => { 55 | if (error.response) { 56 | const { status } = error.response; 57 | // Error is feedback is printed to user if user is not logged in 58 | if (status === 401 || status === 400) { 59 | closeRoomForm(); 60 | printFeedback({ 61 | type: "error", 62 | feedbackMsg: "You are not logged in.", 63 | }); 64 | } 65 | } 66 | console.log(error.message); 67 | }); 68 | }; 69 | 70 | // Delete Room 71 | deleteRoom = (roomId) => { 72 | const { printFeedback } = this.props; 73 | axiosInstance 74 | .delete("rooms/" + roomId) 75 | .then((res) => { 76 | // After successfully deleting these tasks are performed in order 77 | this.props.loadRooms(); 78 | printFeedback({ type: "success", feedbackMsg: "Room deleted" }); 79 | }) 80 | .catch((error) => console.log(error.message)); 81 | }; 82 | 83 | // Directs to the video room 84 | enterRoom = (roomId) => { 85 | const { history } = this.props; 86 | history.push(`/video/${roomId}`); 87 | }; 88 | 89 | componentDidMount() { 90 | this.props.loadRooms(); 91 | } 92 | 93 | render = () => { 94 | const { 95 | closeRoomForm, 96 | isRoomFormOpen, 97 | roomListData, 98 | loadingRooms, 99 | printFeedback, 100 | } = this.props; 101 | const { isUserLoggedIn, isDataArrived } = this.context; 102 | return ( 103 | <> 104 | {/* Create Room Form */} 105 | 106 | 107 | 108 | 109 | {/* User not authentication alert */} 110 | {!isUserLoggedIn && isDataArrived ? ( 111 |
112 | Please Or to create or enter room 113 |
114 | ) : null} 115 | 116 | 117 | {/* List of Rooms */} 118 |
119 | {loadingRooms ? ( 120 | 121 | ) : roomListData.length > 0 ? ( 122 | roomListData.map((data) => { 123 | return ( 124 | 125 | 131 | 132 | ); 133 | }) 134 | ) : ( 135 | No Rooms Yet!! 136 | )} 137 |
138 | 139 | ); 140 | }; 141 | } 142 | export default RoomList; 143 | -------------------------------------------------------------------------------- /frontend/src/components/lobby/room_styles.jsx: -------------------------------------------------------------------------------- 1 | // Custom room styles 2 | const roomStyles = (theme) => ({ 3 | root: { 4 | width: "100%", 5 | }, 6 | heading: { 7 | fontSize: theme.typography.pxToRem(15), 8 | flexBasis: "33.33%", 9 | flexShrink: 0, 10 | }, 11 | secondaryHeading: { 12 | fontSize: theme.typography.pxToRem(15), 13 | color: theme.palette.text.secondary, 14 | }, 15 | gridContainer: { 16 | width: "100%", 17 | display: "grid", 18 | "grid-template-columns": "repeat(1, 1fr)", 19 | "grid-template-rows": "auto", 20 | gap: "1rem", 21 | "justify-content": "center", 22 | "grid-template-areas": ` 23 | "description" 24 | "buttons" 25 | "copy_btn";`, 26 | 27 | [theme.breakpoints.up("md")]: { 28 | "grid-template-columns": "3fr, 1fr", 29 | "grid-template-areas": ` 30 | "description buttons" 31 | "copy_btn copy_btn"`, 32 | }, 33 | }, 34 | gridItemA: { 35 | width: "100%", 36 | "grid-area": "description", 37 | }, 38 | gridItemB: { 39 | width: "100%", 40 | "grid-area": "buttons", 41 | }, 42 | gridItemC: { 43 | "grid-area": "copy_btn", 44 | }, 45 | 46 | enterBtn: { 47 | "background-image": 48 | "linear-gradient(to right, rgba(46,49,146,1) 13%, rgba(27,255,255,1) 96%)", 49 | 50 | transition: "0.5s", 51 | "background-size": " 200% auto", 52 | 53 | "&:hover": { 54 | "background-position": "right center", 55 | }, 56 | }, 57 | }); 58 | export default roomStyles; 59 | -------------------------------------------------------------------------------- /frontend/src/components/navigation_bar/NavigationBar.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { withRouter } from "react-router"; 3 | 4 | // Material UI components 5 | import { withStyles } from "@material-ui/core/styles"; 6 | import AppBar from "@material-ui/core/AppBar"; 7 | import Toolbar from "@material-ui/core/Toolbar"; 8 | import Typography from "@material-ui/core/Typography"; 9 | import IconButton from "@material-ui/core/IconButton"; 10 | import MenuIcon from "@material-ui/icons/Menu"; 11 | import MenuItem from "@material-ui/core/MenuItem"; 12 | import Menu from "@material-ui/core/Menu"; 13 | import InputBase from "@material-ui/core/InputBase"; 14 | import SearchIcon from "@material-ui/icons/Search"; 15 | import AddCircleOutlineIcon from "@material-ui/icons/AddCircleOutline"; 16 | import Tooltip from "@material-ui/core/Tooltip"; 17 | 18 | // Utility components, functions, constants, objects... 19 | import { 20 | UserInfoContext, 21 | AVAILABLE_PATHS, 22 | ALL_PATH_TITLES, 23 | } from "../utilities"; 24 | import navigationBarStyles from "./navigation_bar_styles"; 25 | 26 | class NavigationBar extends Component { 27 | constructor(props) { 28 | super(props); 29 | this.state = { 30 | anchorEl: null, 31 | pageTitle: this.changePageTitle(), 32 | isComponentShown: false, 33 | }; 34 | } 35 | 36 | // Title changes based on what url path user is on 37 | changePageTitle = () => { 38 | const currentUrlPath = this.props.location.pathname; 39 | const { LOBBY_PATH, LOGIN_PATH, REGISTER_PATH } = AVAILABLE_PATHS; 40 | 41 | const { LOBBY_TITLE, LOGIN_TITLE, REGISTER_TITLE } = ALL_PATH_TITLES; 42 | 43 | let pageTitle; 44 | switch (currentUrlPath) { 45 | case LOBBY_PATH: 46 | pageTitle = LOBBY_TITLE; 47 | break; 48 | case LOGIN_PATH: 49 | pageTitle = LOGIN_TITLE; 50 | break; 51 | case REGISTER_PATH: 52 | pageTitle = REGISTER_TITLE; 53 | break; 54 | default: 55 | pageTitle = "404"; 56 | break; 57 | } 58 | 59 | if (currentUrlPath.match("/video/")) { 60 | pageTitle = "Room"; 61 | } 62 | this.setState({ 63 | pageTitle: pageTitle, 64 | }); 65 | return pageTitle; 66 | }; 67 | 68 | static contextType = UserInfoContext; 69 | 70 | // Method to open menu items 71 | handleMenuOpen = (event) => { 72 | this.setState({ 73 | anchorEl: event.currentTarget, 74 | }); 75 | }; 76 | 77 | // Logic to handle each menu item's action 78 | menuAction = (event) => { 79 | const { history, authenticateUser, printFeedback } = this.props; 80 | const { menu } = event.currentTarget.dataset; 81 | const { LOBBY_PATH, LOGIN_PATH, REGISTER_PATH } = AVAILABLE_PATHS; 82 | const { 83 | LOBBY_TITLE, 84 | LOGIN_TITLE, 85 | REGISTER_TITLE, 86 | LOGOUT_TITLE, 87 | } = ALL_PATH_TITLES; 88 | if (menu && history) { 89 | switch (menu) { 90 | case LOBBY_TITLE: 91 | history.push(LOBBY_PATH); 92 | break; 93 | case LOGIN_TITLE: 94 | history.push(LOGIN_PATH); 95 | break; 96 | case REGISTER_TITLE: 97 | history.push(REGISTER_PATH); 98 | break; 99 | case LOGOUT_TITLE: 100 | localStorage.removeItem("access_token"); 101 | localStorage.removeItem("refresh_token"); 102 | authenticateUser(); 103 | printFeedback({ 104 | type: "success", 105 | feedbackMsg: "Logged out successfully", 106 | }); 107 | history.push(LOBBY_PATH); 108 | 109 | break; 110 | default: 111 | break; 112 | } 113 | } 114 | this.setState({ 115 | anchorEl: null, 116 | }); 117 | }; 118 | 119 | // Method to hide 'Search bar' and 'create room button' if url is not the default route 120 | showComponents = () => { 121 | this.changePageTitle(); 122 | if (this.props.location.pathname !== "/") { 123 | this.setState({ 124 | isComponentShown: false, 125 | }); 126 | } else { 127 | this.setState({ 128 | isComponentShown: true, 129 | }); 130 | } 131 | }; 132 | 133 | // This method fires when the component mounts 134 | componentDidMount = () => { 135 | this.showComponents(); 136 | }; 137 | 138 | // And if the route is changed 139 | componentDidUpdate(prevProps) { 140 | if (this.props.location !== prevProps.location) { 141 | this.showComponents(); 142 | this.changePageTitle(); 143 | } 144 | } 145 | 146 | render() { 147 | const { classes, openRoomForm, search, handleSearchChanges } = this.props; 148 | const { anchorEl, pageTitle, isComponentShown } = this.state; 149 | const { userFullName, isUserLoggedIn } = this.context; 150 | const { 151 | LOBBY_TITLE, 152 | LOGIN_TITLE, 153 | REGISTER_TITLE, 154 | LOGOUT_TITLE, 155 | } = ALL_PATH_TITLES; 156 | 157 | // Menu items changes based on whether user is logged in or not 158 | const menuItems = isUserLoggedIn 159 | ? [LOBBY_TITLE, LOGOUT_TITLE] 160 | : [LOBBY_TITLE, LOGIN_TITLE, REGISTER_TITLE]; 161 | return ( 162 |
163 | 164 | 165 | {/* Page Title */} 166 | 167 | {pageTitle} 168 | 169 | 170 | {/* Search Bar */} 171 | {isComponentShown ? ( 172 |
173 |
174 | 175 |
176 | 185 |
186 | ) : null} 187 | 188 | {/* Create room button */} 189 |
190 | {isComponentShown ? ( 191 | 192 | 193 | 194 | 195 | 196 | ) : null} 197 | 198 | {/* User full name */} 199 | 200 | {userFullName && userFullName !== "" ? userFullName : "Anonymous"} 201 | 202 | 203 | {/* Menus */} 204 | 205 | 210 | 211 | 212 | 213 | 221 | {menuItems.map((items, index) => ( 222 | 227 | {items} 228 | 229 | ))} 230 | 231 | 232 | 233 |
234 | ); 235 | } 236 | } 237 | 238 | export default withRouter(withStyles(navigationBarStyles)(NavigationBar)); 239 | -------------------------------------------------------------------------------- /frontend/src/components/navigation_bar/navigation_bar_styles.jsx: -------------------------------------------------------------------------------- 1 | import { alpha } from '@material-ui/core/styles' 2 | 3 | // Custom styling for navigation bar 4 | const navigationBarStyles = (theme) => ({ 5 | root: { 6 | flexGrow: 1, 7 | }, 8 | appBar: { 9 | background: 10 | "linear-gradient(10deg, rgba(46,49,146,1) 13%, rgba(27,255,255,1) 96%)", 11 | }, 12 | pageTitle: { 13 | marginRight: theme.spacing(2), 14 | }, 15 | 16 | search: { 17 | position: "relative", 18 | borderRadius: theme.shape.borderRadius, 19 | backgroundColor: alpha(theme.palette.common.white, 0.15), 20 | "&:hover": { 21 | backgroundColor: alpha(theme.palette.common.white, 0.25), 22 | }, 23 | marginRight: theme.spacing(2), 24 | marginLeft: 0, 25 | width: "100%", 26 | [theme.breakpoints.up("sm")]: { 27 | marginLeft: theme.spacing(3), 28 | width: "auto", 29 | }, 30 | }, 31 | searchIcon: { 32 | padding: theme.spacing(0, 2), 33 | height: "100%", 34 | position: "absolute", 35 | pointerEvents: "none", 36 | display: "flex", 37 | alignItems: "center", 38 | justifyContent: "center", 39 | }, 40 | inputRoot: { 41 | color: "inherit", 42 | }, 43 | inputInput: { 44 | padding: theme.spacing(1, 1, 1, 0), 45 | // vertical padding + font size from searchIcon 46 | paddingLeft: `calc(1em + ${theme.spacing(4)}px)`, 47 | transition: theme.transitions.create("width"), 48 | width: "100%", 49 | [theme.breakpoints.up("md")]: { 50 | width: "20ch", 51 | }, 52 | }, 53 | menuButton: { 54 | marginRight: theme.spacing(2), 55 | }, 56 | username: { 57 | marginRight: theme.spacing(2), 58 | marginLeft: theme.spacing(1), 59 | display: "none", 60 | [theme.breakpoints.up("sm")]: { 61 | display: "block", 62 | }, 63 | }, 64 | }); 65 | export default navigationBarStyles; 66 | -------------------------------------------------------------------------------- /frontend/src/components/utilities/CONSTANTS.jsx: -------------------------------------------------------------------------------- 1 | 2 | export const BASE_API_URL = "http://127.0.0.1:8000/api/"; 3 | export const webSocketUrl = () => { 4 | let websocketProtocol; 5 | if (window.location.protocol === "https:") { 6 | websocketProtocol = "wss://"; 7 | } else { 8 | websocketProtocol = "ws://"; 9 | } 10 | return websocketProtocol + "127.0.0.1:8000/video/"; // Use this for stand-alone app 11 | }; 12 | 13 | export const AVAILABLE_PATHS = { 14 | LOBBY_PATH: "/", 15 | LOGIN_PATH: "/login", 16 | REGISTER_PATH: "/register", 17 | VIDEO_ROOM_PATH: "/video/:roomId", 18 | }; 19 | 20 | export const ALL_PATH_TITLES = { 21 | LOBBY_TITLE: "Lobby", 22 | LOGIN_TITLE: "Login", 23 | REGISTER_TITLE: "Register", 24 | LOGOUT_TITLE: "Logout", 25 | }; 26 | -------------------------------------------------------------------------------- /frontend/src/components/utilities/authForms_validation_schema.jsx: -------------------------------------------------------------------------------- 1 | import * as yup from "yup"; 2 | 3 | // Validations Schemas for authentication 4 | 5 | export let loginValidationSchema = yup.object().shape({ 6 | email: yup.string().email().required(), 7 | password: yup.string().required(), 8 | }); 9 | 10 | export let registerValidationSchema = yup.object().shape({ 11 | email: yup.string().email().required(), 12 | firstName: yup.string().required().label("First Name"), 13 | lastName: yup.string().required().label("Last Name"), 14 | password: yup.string().min(8).required(), 15 | confirmation: yup 16 | .string() 17 | .oneOf([yup.ref("password"), null], "Passwords must match") 18 | .required(), 19 | }); 20 | -------------------------------------------------------------------------------- /frontend/src/components/utilities/axios.jsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | // Utility functions, constants, objects... 4 | import { BASE_API_URL } from "./CONSTANTS"; 5 | 6 | function getCookie(name) { 7 | let cookieValue = null; 8 | if (document.cookie && document.cookie !== "") { 9 | const cookies = document.cookie.split(";"); 10 | for (let i = 0; i < cookies.length; i++) { 11 | const cookie = cookies[i].trim(); 12 | // Does this cookie string begin with the name we want? 13 | if (cookie.substring(0, name.length + 1) === name + "=") { 14 | cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); 15 | break; 16 | } 17 | } 18 | } 19 | return cookieValue; 20 | } 21 | const csrftoken = getCookie("csrftoken"); 22 | 23 | const axiosInstance = axios.create({ 24 | baseURL: BASE_API_URL, 25 | timeout: 5000, 26 | headers: { 27 | "Content-Type": "application/json", 28 | accept: "application/json", 29 | "X-CSRFToken": csrftoken, 30 | Authorization: "Bearer " + localStorage.getItem("access_token"), 31 | }, 32 | }); 33 | export default axiosInstance; 34 | 35 | export const getRoomsList = (axiosInstance, search = "") => { 36 | delete axiosInstance.defaults.headers["Authorization"]; 37 | 38 | if (search !== "") { 39 | return axiosInstance.get(`rooms/?search=${search}`); 40 | } 41 | 42 | return axiosInstance.get("rooms/"); 43 | }; 44 | 45 | export const validateToken = (axiosInstance, token) => { 46 | return axiosInstance.post("token/verify/", { 47 | token: token, 48 | }); 49 | }; 50 | 51 | export const refreshingAccessToken = () => { 52 | const access_token = localStorage.getItem("access_token"); 53 | validateToken(axiosInstance, access_token) 54 | .then((response) => { 55 | if (response.status === 200) { 56 | axiosInstance.defaults.headers["Authorization"] = 57 | "Bearer " + access_token; 58 | } 59 | }) 60 | .catch((error) => { 61 | const refresh_token = localStorage.getItem("refresh_token"); 62 | if ( 63 | (error.response.status === 400 || error.response.status === 401) && 64 | refresh_token 65 | ) { 66 | axiosInstance 67 | .post("token/refresh/", { 68 | refresh: refresh_token, 69 | }) 70 | .then(({ status, data }) => { 71 | if (status === 200) { 72 | localStorage.setItem("access_token", data.access); 73 | axiosInstance.defaults.headers["Authorization"] = 74 | "Bearer " + data.access; 75 | } 76 | }) 77 | .catch((err) => { 78 | console.log(err.message); 79 | }); 80 | } 81 | }); 82 | }; 83 | -------------------------------------------------------------------------------- /frontend/src/components/utilities/components/Feedback.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | // Material UI components 4 | import Snackbar from "@material-ui/core/Snackbar"; 5 | import Alert from "@material-ui/lab/Alert"; 6 | 7 | function Feedback(props) { 8 | const { severity, feedbackMsg, isFeedbackOpen, closeFeedback } = props; 9 | return ( 10 | <> 11 | 20 | 21 | {feedbackMsg} 22 | 23 | 24 | 25 | ); 26 | } 27 | 28 | export default Feedback; 29 | -------------------------------------------------------------------------------- /frontend/src/components/utilities/components/FormikUIField.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Field, ErrorMessage } from "formik"; 3 | 4 | // Material UI components 5 | import TextField from "@material-ui/core/TextField"; 6 | 7 | function FormikUIField(props) { 8 | const { 9 | name, 10 | label, 11 | type = "text", 12 | MUComponent = TextField, 13 | ...restOfProps 14 | } = props; 15 | return ( 16 |
17 | } 23 | as={MUComponent} 24 | /> 25 |
26 | ); 27 | } 28 | 29 | export default FormikUIField; 30 | -------------------------------------------------------------------------------- /frontend/src/components/utilities/components/FormikUISelect.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Field, ErrorMessage } from "formik"; 3 | 4 | // Material UI components 5 | import MenuItem from "@material-ui/core/MenuItem"; 6 | import FormControl from "@material-ui/core/FormControl"; 7 | import Select from "@material-ui/core/Select"; 8 | import FormHelperText from "@material-ui/core/FormHelperText"; 9 | import InputLabel from "@material-ui/core/InputLabel"; 10 | 11 | function MaterialSelectUI(props) { 12 | const { label, children, value, name, errorString, onChange, onBlur } = props; 13 | return ( 14 | 15 | {label} 16 | 19 | {errorString} 20 | 21 | ); 22 | } 23 | 24 | function FormikUISelect(props) { 25 | const { name, label, items = [] } = props; 26 | return ( 27 | } 31 | as={MaterialSelectUI} 32 | > 33 | {items.map((item) => ( 34 | 35 | {item.label} 36 | 37 | ))} 38 | 39 | ); 40 | } 41 | 42 | export default FormikUISelect; 43 | -------------------------------------------------------------------------------- /frontend/src/components/utilities/components/Loading.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | // Material UI components 4 | import CircularProgress from "@material-ui/core/CircularProgress"; 5 | import Container from "@material-ui/core/Container"; 6 | 7 | function Loading() { 8 | return ( 9 | 10 | 11 | 12 | ); 13 | } 14 | 15 | export default Loading; 16 | -------------------------------------------------------------------------------- /frontend/src/components/utilities/components/RouterUILink.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link as RouterLink } from "react-router-dom"; 3 | 4 | // Material UI components 5 | import Link from "@material-ui/core/Link"; 6 | 7 | // Router link component is modified with material ui 8 | function RouterUILink(props) { 9 | const { innerText, linkTo } = props; 10 | return ( 11 | 12 | {innerText} 13 | 14 | ); 15 | } 16 | 17 | export default RouterUILink; 18 | -------------------------------------------------------------------------------- /frontend/src/components/utilities/components/UserInfoProvider.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const UserInfoContext = React.createContext({ 4 | isDataArrived: false, 5 | userId: null, 6 | userFullName: "", 7 | isUserLoggedIn: false, 8 | }); 9 | 10 | function UserInfoProvider(props) { 11 | return ( 12 | 13 | {props.children} 14 | 15 | ); 16 | } 17 | 18 | export default UserInfoProvider; 19 | -------------------------------------------------------------------------------- /frontend/src/components/utilities/components/index.jsx: -------------------------------------------------------------------------------- 1 | import Feedback from "./Feedback"; 2 | import FormikUIField from "./FormikUIField"; 3 | import FormikUISelect from "./FormikUISelect"; 4 | import Loading from "./Loading"; 5 | import RouterUILink from "./RouterUILink"; 6 | import UserInfoProvider, { UserInfoContext } from "./UserInfoProvider"; 7 | 8 | export { 9 | Feedback, 10 | FormikUIField, 11 | FormikUISelect, 12 | Loading, 13 | RouterUILink, 14 | UserInfoProvider, 15 | UserInfoContext, 16 | }; 17 | -------------------------------------------------------------------------------- /frontend/src/components/utilities/index.jsx: -------------------------------------------------------------------------------- 1 | // Components 2 | import { 3 | Feedback, 4 | FormikUIField, 5 | FormikUISelect, 6 | Loading, 7 | RouterUILink, 8 | UserInfoProvider, 9 | UserInfoContext, 10 | } from "./components/"; 11 | 12 | // Functions,Constants,Objects... 13 | import { webSocketUrl, AVAILABLE_PATHS, ALL_PATH_TITLES } from "./CONSTANTS"; 14 | import axiosInstance, { 15 | validateToken, 16 | refreshingAccessToken, 17 | getRoomsList, 18 | } from "./axios"; 19 | import { 20 | loginValidationSchema, 21 | registerValidationSchema, 22 | } from "./authForms_validation_schema"; 23 | import roomFormValidationSchema from "./roomForms_validation_schema"; 24 | 25 | export { 26 | Feedback, 27 | FormikUIField, 28 | FormikUISelect, 29 | Loading, 30 | RouterUILink, 31 | UserInfoProvider, 32 | UserInfoContext, 33 | webSocketUrl, 34 | AVAILABLE_PATHS, 35 | ALL_PATH_TITLES, 36 | axiosInstance, 37 | validateToken, 38 | refreshingAccessToken, 39 | getRoomsList, 40 | loginValidationSchema, 41 | registerValidationSchema, 42 | roomFormValidationSchema, 43 | }; 44 | -------------------------------------------------------------------------------- /frontend/src/components/utilities/roomForms_validation_schema.jsx: -------------------------------------------------------------------------------- 1 | import * as yup from "yup"; 2 | 3 | const typeOf = ["OTA", "IO"]; 4 | let roomFormValidationSchema = yup.object().shape({ 5 | title: yup.string().required(), 6 | description: yup.string(), 7 | typeOf: yup.string().required().oneOf(typeOf), 8 | }); 9 | export default roomFormValidationSchema; 10 | -------------------------------------------------------------------------------- /frontend/src/components/video_room/Video.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | // Material UI components 4 | import Typography from "@material-ui/core/Typography"; 5 | import { withStyles } from "@material-ui/core/styles"; 6 | 7 | // Utility components, functions, constants, objects... 8 | import videoRoomStyles from "./video_room_styles"; 9 | 10 | export class Video extends Component { 11 | constructor(props) { 12 | super(props); 13 | 14 | this.state = { 15 | isMouseHovering: false, 16 | }; 17 | } 18 | 19 | // These Hover method is just for style purposes 20 | mouseHoverIn = () => { 21 | this.setState({ 22 | isMouseHovering: true, 23 | }); 24 | }; 25 | mouseHoverOut = () => { 26 | this.setState({ 27 | isMouseHovering: false, 28 | }); 29 | }; 30 | componentDidMount = () => { 31 | // remote stream is added to video tags 32 | const { peer, user_id } = this.props; 33 | if (peer && user_id) { 34 | peer.on("stream", (stream) => { 35 | document.getElementById(`remote-${user_id}`).srcObject = stream; 36 | }); 37 | } 38 | }; 39 | 40 | render() { 41 | const { user_full_name, id, classes, isLocalUser } = this.props; 42 | 43 | // Style is changed if stream is local or remote 44 | let videoDivClass; 45 | let style; 46 | if (isLocalUser) { 47 | videoDivClass = classes.localVideoDiv; 48 | } else { 49 | videoDivClass = classes.remoteVideoDiv; 50 | 51 | if (this.state.isMouseHovering) { 52 | style = { opacity: 1 }; 53 | } else { 54 | style = { opacity: 0 }; 55 | } 56 | } 57 | 58 | return ( 59 |
64 | 70 | 71 | {!isLocalUser ? ( 72 |
73 | 74 | {user_full_name ? user_full_name : "Anonymous"} 75 | 76 |
77 | ) : null} 78 |
79 | ); 80 | } 81 | } 82 | 83 | export default withStyles(videoRoomStyles)(Video); 84 | -------------------------------------------------------------------------------- /frontend/src/components/video_room/VideoRoom.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import Peer from "simple-peer"; 3 | 4 | // Material UI components 5 | import { withStyles } from "@material-ui/core/styles"; 6 | import Fab from "@material-ui/core/Fab"; 7 | import VolumeUpRoundedIcon from "@material-ui/icons/VolumeUpRounded"; 8 | import VolumeOffRoundedIcon from "@material-ui/icons/VolumeOffRounded"; 9 | import VideocamRoundedIcon from "@material-ui/icons/VideocamRounded"; 10 | import VideocamOffRoundedIcon from "@material-ui/icons/VideocamOffRounded"; 11 | import ExitToAppRoundedIcon from "@material-ui/icons/ExitToAppRounded"; 12 | import Tooltip from "@material-ui/core/Tooltip"; 13 | import Alert from "@material-ui/lab/Alert"; 14 | 15 | // Components 16 | import Video from "./Video"; 17 | 18 | // Utility components, functions, constants, objects... 19 | import { UserInfoContext, webSocketUrl, AVAILABLE_PATHS } from "../utilities"; 20 | import videoRoomStyles from "./video_room_styles"; 21 | 22 | export class VideoRoom extends Component { 23 | constructor(props) { 24 | super(props); 25 | 26 | this.state = { 27 | websocket: null, 28 | stream: null, 29 | usersConnected: [], 30 | peersEstablished: [], 31 | 32 | isVideoMuted: false, 33 | isAudioMuted: false, 34 | contentLoading: true, 35 | 36 | isVideoRoomAccessible: true, 37 | }; 38 | } 39 | 40 | muteVideo = () => { 41 | const stream = document.getElementById("localVideo").srcObject; 42 | if (!stream.getVideoTracks()[0]) return; 43 | stream.getVideoTracks()[0].enabled = !stream.getVideoTracks()[0].enabled; 44 | this.setState({ 45 | isVideoMuted: !stream.getVideoTracks()[0].enabled, 46 | }); 47 | }; 48 | muteAudio = () => { 49 | const stream = document.getElementById("localVideo").srcObject; 50 | if (!stream.getAudioTracks()[0]) return; 51 | stream.getAudioTracks()[0].enabled = !stream.getAudioTracks()[0].enabled; 52 | this.setState({ 53 | isAudioMuted: !stream.getAudioTracks()[0].enabled, 54 | }); 55 | }; 56 | leaveRoom = () => { 57 | const { history } = this.props; 58 | history.push(AVAILABLE_PATHS.LOBBY_PATH); 59 | }; 60 | 61 | // Creates offer to send it to other user in the room 62 | CreatePeer = (currentUserId, otherUserId, currentUserStream = null) => { 63 | 64 | // User creates peer as initiator 65 | const peer = new Peer({ 66 | initiator: true, 67 | trickle: false, 68 | stream: currentUserStream, 69 | }); 70 | 71 | // Offer is sent 72 | peer.on("signal", (signal) => { 73 | this.state.websocket.send( 74 | JSON.stringify({ 75 | type: "sending_offer", 76 | from: currentUserId, 77 | to: otherUserId, 78 | offer: signal, 79 | }) 80 | ); 81 | }); 82 | 83 | // Peer is then returned 84 | return peer; 85 | }; 86 | 87 | // Creates answer in response to the offer received 88 | addPeer = ( 89 | currentUserId, 90 | otherUserId, 91 | receivedOffer, 92 | currentUserStream = null 93 | ) => { 94 | 95 | // User creates peer but not as an initiator 96 | const peer = new Peer({ 97 | initiator: false, 98 | trickle: false, 99 | stream: currentUserStream, 100 | }); 101 | 102 | 103 | // The offer that was sent is set as remote description 104 | peer.signal(receivedOffer); 105 | 106 | // Answer is sent back to the user who sent the offer 107 | peer.on("signal", (signal) => { 108 | this.state.websocket.send( 109 | JSON.stringify({ 110 | type: "sending_answer", 111 | from: currentUserId, 112 | to: otherUserId, 113 | answer: signal, 114 | }) 115 | ); 116 | }); 117 | 118 | // Peer is then returned 119 | return peer; 120 | }; 121 | 122 | // Function to send offers to each users as initiator that are connected to the room 123 | sendSignalsToAll = (currentUserId, stream = null) => { 124 | const peers = []; 125 | this.state.usersConnected.forEach((otherUser) => { 126 | if (otherUser.user_id !== currentUserId) { 127 | const peer = this.CreatePeer(currentUserId, otherUser.user_id, stream); 128 | peers.push({ 129 | user_id: otherUser.user_id, 130 | user_full_name: otherUser.user_full_name, 131 | peer: peer, 132 | }); 133 | } 134 | }); 135 | 136 | this.setState({ 137 | peersEstablished: peers, 138 | }); 139 | }; 140 | 141 | componentDidMount = () => { 142 | const { 143 | printFeedback, 144 | match: { params }, 145 | } = this.props; 146 | 147 | // Checks whether 'navigator.mediaDevices' is available for this browser or not 148 | if (!navigator.mediaDevices) { 149 | this.setState({ isVideoRoomAccessible: false }); 150 | printFeedback({ 151 | type: "error", 152 | feedbackMsg: 153 | "this video room is not accessible because the site is not running on secure protocol, i.e. 'HTTPS'", 154 | }); 155 | return; 156 | } 157 | // Extracting current user info 158 | const { userId, userFullName } = this.context; 159 | 160 | // Websocket connection is made 161 | const websocket = new WebSocket(webSocketUrl() + `${params.roomId}/`); 162 | this.setState({ 163 | websocket: websocket, 164 | }); 165 | 166 | websocket.onopen = () => { 167 | this.setState({ 168 | contentLoading: true, 169 | }); 170 | 171 | // Send all users in the room new user joined 172 | websocket.send( 173 | JSON.stringify({ 174 | type: "new_user_joined", 175 | from: userId, 176 | user_full_name: userFullName, 177 | token: localStorage.getItem("access_token"), 178 | }) 179 | ); 180 | }; 181 | 182 | websocket.onmessage = (payload) => { 183 | // Message from backend 184 | const data = JSON.parse(payload.data); 185 | 186 | switch (data.type) { 187 | case "new_user_joined": 188 | this.setState({ 189 | usersConnected: data.users_connected, 190 | }); 191 | 192 | // If user joined is current user, the user is requested to enable the media devices and offer is created and sent to other user 193 | if (userId === data.from) { 194 | navigator.mediaDevices 195 | .getUserMedia({ 196 | video: true, 197 | audio: true, 198 | }) 199 | .then((stream) => { 200 | this.setState({ stream: stream }); 201 | document.getElementById("localVideo").srcObject = stream; 202 | this.sendSignalsToAll(userId, stream); 203 | }) 204 | .catch((error) => { 205 | this.setState({ isVideoRoomAccessible: false }); 206 | printFeedback({ 207 | type: "error", 208 | feedbackMsg: 209 | "you need to enable media devices inorder to use access this room", 210 | }); 211 | console.log(error.message); 212 | return; 213 | }); 214 | } 215 | 216 | // Message is send to other user that new user joined 217 | if (userId !== data.from) { 218 | const user = this.state.usersConnected.find( 219 | (eachUser) => eachUser.user_id === data.from 220 | ); 221 | printFeedback({ 222 | type: "success", 223 | feedbackMsg: `${user.user_full_name} joined this room`, 224 | }); 225 | console.log(`User No. ${data.from} joined this room`); 226 | } 227 | break; 228 | 229 | // Offer is received here by others who then store it in there state and sends the answer 230 | case "sending_offer": 231 | if (data.to === userId) { 232 | console.log("offer_received"); 233 | const peer = this.addPeer( 234 | userId, 235 | data.from, 236 | data.offer, 237 | this.state.stream 238 | ); 239 | this.setState(({ peersEstablished }) => { 240 | const user = this.state.usersConnected.find( 241 | (eachUser) => eachUser.user_id === data.from 242 | ); 243 | let newPeersList = [ 244 | ...peersEstablished, 245 | { 246 | user_id: data.from, 247 | user_full_name: user.user_full_name, 248 | peer: peer, 249 | }, 250 | ]; 251 | 252 | // Checks whether the peer with same user id exists in the 'peersEstablished' state, that peer is then removed 253 | const userPeer = this.state.peersEstablished.find( 254 | (eachUser) => eachUser.user_id === data.from 255 | ); 256 | if (userPeer) { 257 | const newList = this.state.peersEstablished.filter( 258 | (peer) => userPeer.user_id !== peer.user_id 259 | ); 260 | 261 | newPeersList = [ 262 | ...newList, 263 | { 264 | user_id: data.from, 265 | user_full_name: user.user_full_name, 266 | peer: peer, 267 | }, 268 | ]; 269 | } 270 | 271 | return { 272 | peersEstablished: newPeersList, 273 | }; 274 | }); 275 | } 276 | break; 277 | 278 | // Answer is received here by the user who just joined 279 | case "sending_answer": 280 | if (data.to === userId) { 281 | console.log("answer_received"); 282 | const userPeer = this.state.peersEstablished.find( 283 | (eachUser) => eachUser.user_id === data.from 284 | ); 285 | 286 | // Answer is set as remote description 287 | userPeer.peer.signal(data.answer); 288 | } 289 | 290 | break; 291 | case "disconnected": 292 | if (data.from !== userId) { 293 | const user = this.state.usersConnected.find( 294 | (eachUser) => eachUser.user_id === data.from 295 | ); 296 | 297 | // Feedback is sent to users about who just disconnected 298 | if (user) { 299 | console.log(`User No. ${data.from} disconnected`); 300 | printFeedback({ 301 | type: "error", 302 | feedbackMsg: `${user.user_full_name} left`, 303 | }); 304 | 305 | // Peer associated with the user that just disconnected is destroyed 306 | const userPeer = this.state.peersEstablished.find( 307 | (eachUser) => eachUser.user_id === data.from 308 | ); 309 | if (userPeer) { 310 | userPeer.peer.destroy(); 311 | const newPeersList = this.state.peersEstablished.filter( 312 | (peer) => userPeer.user_id !== peer.user_id 313 | ); 314 | this.setState({ peersEstablished: newPeersList }); 315 | } 316 | } 317 | } 318 | break; 319 | default: 320 | break; 321 | } 322 | }; 323 | }; 324 | 325 | componentWillUnmount = () => { 326 | // Websocket is closed 327 | const { websocket, peersEstablished, stream } = this.state; 328 | if (websocket) { 329 | this.state.websocket.close(); 330 | } 331 | 332 | // All peers is destroyed 333 | peersEstablished.forEach(({ peer }) => { 334 | peer.destroy(); 335 | }); 336 | 337 | // Streams are stopped 338 | if (stream) { 339 | stream.getTracks().forEach((track) => { 340 | track.stop(); 341 | }); 342 | } 343 | 344 | // All state is cleared 345 | this.setState({ 346 | websocket: null, 347 | stream: null, 348 | usersConnected: [], 349 | peersEstablished: [], 350 | 351 | isVideoMuted: true, 352 | isAudioMuted: true, 353 | }); 354 | }; 355 | 356 | static contextType = UserInfoContext; 357 | render() { 358 | const { 359 | isVideoRoomAccessible, 360 | isVideoMuted, 361 | isAudioMuted, 362 | peersEstablished, 363 | } = this.state; 364 | 365 | const { userFullName } = this.context; 366 | const { classes } = this.props; 367 | return ( 368 |
369 | {isVideoRoomAccessible ? ( 370 | <> 371 | {/* Action Buttons */} 372 |
373 | {/* Video Mute/Unmute */} 374 | 375 | 380 | {isVideoMuted ? ( 381 | 382 | ) : ( 383 | 384 | )} 385 | 386 | 387 | 388 | {/* Audio Mute/Unmute */} 389 | 390 | 395 | {isAudioMuted ? ( 396 | 397 | ) : ( 398 | 399 | )} 400 | 401 | 402 | 403 | {/* Leave Room */} 404 | 405 | 410 | 411 | 412 | 413 |
414 | 415 | {/* Locale Video */} 416 |
443 | ); 444 | } 445 | } 446 | 447 | export default withStyles(videoRoomStyles)(VideoRoom); 448 | -------------------------------------------------------------------------------- /frontend/src/components/video_room/VideoRoomRoute.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Route, Redirect } from "react-router-dom"; 3 | 4 | // Components 5 | import VideoRoom from "./VideoRoom"; 6 | 7 | // Utility components, functions, constants, objects... 8 | import { AVAILABLE_PATHS, Loading } from "../utilities"; 9 | 10 | // Custom route for the video room to redirect user if user is not logged in 11 | function VideoRoomRoute(props) { 12 | const { userData, printFeedback, ...restOfProps } = props; 13 | const { isUserLoggedIn, isDataArrived } = userData; 14 | return ( 15 | 18 | isDataArrived && !isUserLoggedIn ? ( 19 | 20 | ) : isDataArrived && isUserLoggedIn ? ( 21 | 26 | ) : ( 27 | 28 | ) 29 | } 30 | /> 31 | ); 32 | } 33 | 34 | export default VideoRoomRoute; 35 | -------------------------------------------------------------------------------- /frontend/src/components/video_room/video_room_styles.jsx: -------------------------------------------------------------------------------- 1 | // Custom Video styling 2 | const videoRoomStyles = (theme) => ({ 3 | floatingButtons: { 4 | zIndex: 1, 5 | position: "fixed", 6 | bottom: 0, 7 | left: "50%", 8 | transform: "translate(-50%, -50%)", 9 | }, 10 | floatingButton: { 11 | margin: "0 0.5rem", 12 | }, 13 | videoGrid: { 14 | marginTop: "1rem", 15 | marginBottom: "15rem", 16 | 17 | width: "100%", 18 | display: "grid", 19 | "grid-template-columns": "repeat(2, 1fr)", 20 | "grid-template-rows": "auto", 21 | gap: "0.3rem", 22 | [theme.breakpoints.up("md")]: { 23 | marginBottom: "20rem", 24 | "grid-template-columns": "repeat(4, 1fr)", 25 | }, 26 | }, 27 | 28 | remoteVideoDiv: { 29 | overflow: "hidden", 30 | borderRadius: "0.5rem", 31 | background: "gray", 32 | position: "relative", 33 | }, 34 | localVideoDiv: { 35 | zIndex: 2, 36 | borderRadius: "0.5rem", 37 | position: "fixed", 38 | bottom: 40, 39 | left: "50%", 40 | transform: "translate(-50%, -50%)", 41 | width: "150px", 42 | height: "100px", 43 | background: "black", 44 | [theme.breakpoints.up("md")]: { 45 | bottom: 30, 46 | width: "200px", 47 | height: "150px", 48 | }, 49 | }, 50 | video: { 51 | borderRadius: "0.5rem", 52 | objectFit: "cover", 53 | width: "100%", 54 | height: "100%", 55 | }, 56 | 57 | userDetail: { 58 | textAlign: "center", 59 | width: "100%", 60 | borderRadius: "0.5rem", 61 | transition: "0.3s", 62 | position: "absolute", 63 | background: "rgba(177, 202, 248, 0.459)", 64 | padding: "0.3rem 1rem", 65 | bottom: -20, 66 | left: "50%", 67 | transform: "translate(-50%, -50%)", 68 | }, 69 | 70 | alert: { 71 | marginTop: "1rem", 72 | }, 73 | }); 74 | export default videoRoomStyles; 75 | -------------------------------------------------------------------------------- /frontend/src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import App from "./App"; 4 | 5 | ReactDOM.render( 6 | 7 | 8 | , 9 | document.getElementById("root") 10 | ); 11 | -------------------------------------------------------------------------------- /readme_screenshots/screenshot16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlad99764/full-stack-react-django/2daa7139a3121539e13797c3769689ba4639c32d/readme_screenshots/screenshot16.png -------------------------------------------------------------------------------- /readme_screenshots/screenshot17.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vlad99764/full-stack-react-django/2daa7139a3121539e13797c3769689ba4639c32d/readme_screenshots/screenshot17.png --------------------------------------------------------------------------------