From c39870067c3d8ba764d50f0abb033bcb4b570caa Mon Sep 17 00:00:00 2001 From: Joy Liu <34288846+joyliu-q@users.noreply.github.com> Date: Mon, 6 Feb 2023 22:00:51 -0500 Subject: [PATCH 01/11] Initial commit --- README.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 00000000..4b30d58a --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# fly-ohq +[Example] OHQ Deployed using Fly.io From 05ef5eb259ec90b700636ec0f1c20545b22b8467 Mon Sep 17 00:00:00 2001 From: Alexander Kyimpopkin <39439486+alxkp@users.noreply.github.com> Date: Mon, 6 Feb 2023 22:26:41 -0500 Subject: [PATCH 02/11] created flyio config file --- backend/.dockerignore | 17 + backend/Dockerfile | 21 + backend/Pipfile | 49 + backend/Pipfile.lock | 1621 +++++++++++++++++ backend/docker-compose.yaml | 17 + backend/fly.toml | 43 + backend/manage.py | 21 + backend/officehoursqueue/__init__.py | 4 + backend/officehoursqueue/asgi.py | 18 + backend/officehoursqueue/celery.py | 19 + backend/officehoursqueue/routing.py | 17 + backend/officehoursqueue/settings/__init__.py | 0 backend/officehoursqueue/settings/base.py | 187 ++ backend/officehoursqueue/settings/ci.py | 6 + .../officehoursqueue/settings/development.py | 17 + .../officehoursqueue/settings/production.py | 42 + backend/officehoursqueue/settings/staging.py | 1 + .../templates/emails/base.html | 79 + .../templates/emails/course_added.html | 19 + .../templates/emails/course_invitation.html | 19 + backend/officehoursqueue/templates/redoc.html | 21 + backend/officehoursqueue/urls.py | 43 + backend/officehoursqueue/wsgi.py | 17 + backend/ohq/__init__.py | 0 backend/ohq/admin.py | 28 + backend/ohq/apps.py | 5 + backend/ohq/backends.py | 19 + backend/ohq/filters.py | 35 + backend/ohq/invite.py | 54 + backend/ohq/management/__init__.py | 0 backend/ohq/management/commands/__init__.py | 0 backend/ohq/management/commands/archive.py | 24 + .../management/commands/calculatewaittimes.py | 11 + .../ohq/management/commands/course_stat.py | 52 + .../ohq/management/commands/createcourse.py | 58 + backend/ohq/management/commands/populate.py | 465 +++++ .../management/commands/queue_daily_stat.py | 51 + .../management/commands/queue_heatmap_stat.py | 36 + backend/ohq/migrations/0001_initial.py | 259 +++ .../ohq/migrations/0002_auto_20200816_1727.py | 73 + .../ohq/migrations/0003_auto_20200822_1116.py | 28 + .../ohq/migrations/0004_auto_20200825_1344.py | 16 + .../ohq/migrations/0005_auto_20201016_1702.py | 21 + .../ohq/migrations/0006_auto_20210105_2000.py | 39 + backend/ohq/migrations/0007_announcement.py | 45 + .../ohq/migrations/0008_auto_20210119_2218.py | 31 + .../ohq/migrations/0009_auto_20210201_2224.py | 80 + .../ohq/migrations/0010_auto_20210405_1720.py | 28 + .../ohq/migrations/0010_auto_20210407_0145.py | 14 + .../migrations/0011_merge_20210415_2110.py | 13 + ...eue_require_video_chat_url_on_questions.py | 43 + .../ohq/migrations/0013_auto_20210924_2056.py | 15 + .../0014_question_student_descriptor.py | 18 + .../ohq/migrations/0015_question_templates.py | 16 + .../ohq/migrations/0016_auto_20211008_2136.py | 21 + .../ohq/migrations/0017_auto_20211031_1615.py | 18 + .../ohq/migrations/0018_auto_20220125_0344.py | 16 + .../ohq/migrations/0019_auto_20211114_1800.py | 57 + backend/ohq/migrations/__init__.py | 0 backend/ohq/models.py | 413 +++++ backend/ohq/pagination.py | 9 + backend/ohq/permissions.py | 505 +++++ backend/ohq/queues.py | 27 + backend/ohq/routing.py | 8 + backend/ohq/schemas.py | 94 + backend/ohq/serializers.py | 576 ++++++ backend/ohq/sms.py | 25 + backend/ohq/statistics.py | 227 +++ backend/ohq/tasks.py | 21 + backend/ohq/urls.py | 66 + backend/ohq/views.py | 770 ++++++++ backend/pyproject.toml | 2 + backend/scripts/asgi-run | 10 + backend/setup.cfg | 25 + backend/tests/__init__.py | 0 backend/tests/ohq/__init__.py | 0 backend/tests/ohq/test_backends.py | 34 + backend/tests/ohq/test_commands.py | 1092 +++++++++++ backend/tests/ohq/test_filters.py | 89 + backend/tests/ohq/test_invite.py | 72 + backend/tests/ohq/test_live.py | 218 +++ backend/tests/ohq/test_migrations.py | 85 + backend/tests/ohq/test_models.py | 309 ++++ backend/tests/ohq/test_permissions.py | 1312 +++++++++++++ backend/tests/ohq/test_queues.py | 81 + backend/tests/ohq/test_serializers.py | 725 ++++++++ backend/tests/ohq/test_sms.py | 79 + backend/tests/ohq/test_tasks.py | 91 + backend/tests/ohq/test_views.py | 446 +++++ 89 files changed, 11398 insertions(+) create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile create mode 100644 backend/Pipfile create mode 100644 backend/Pipfile.lock create mode 100644 backend/docker-compose.yaml create mode 100644 backend/fly.toml create mode 100755 backend/manage.py create mode 100644 backend/officehoursqueue/__init__.py create mode 100644 backend/officehoursqueue/asgi.py create mode 100644 backend/officehoursqueue/celery.py create mode 100644 backend/officehoursqueue/routing.py create mode 100644 backend/officehoursqueue/settings/__init__.py create mode 100644 backend/officehoursqueue/settings/base.py create mode 100644 backend/officehoursqueue/settings/ci.py create mode 100644 backend/officehoursqueue/settings/development.py create mode 100644 backend/officehoursqueue/settings/production.py create mode 100644 backend/officehoursqueue/settings/staging.py create mode 100644 backend/officehoursqueue/templates/emails/base.html create mode 100644 backend/officehoursqueue/templates/emails/course_added.html create mode 100644 backend/officehoursqueue/templates/emails/course_invitation.html create mode 100644 backend/officehoursqueue/templates/redoc.html create mode 100644 backend/officehoursqueue/urls.py create mode 100644 backend/officehoursqueue/wsgi.py create mode 100644 backend/ohq/__init__.py create mode 100644 backend/ohq/admin.py create mode 100644 backend/ohq/apps.py create mode 100644 backend/ohq/backends.py create mode 100644 backend/ohq/filters.py create mode 100644 backend/ohq/invite.py create mode 100644 backend/ohq/management/__init__.py create mode 100644 backend/ohq/management/commands/__init__.py create mode 100644 backend/ohq/management/commands/archive.py create mode 100644 backend/ohq/management/commands/calculatewaittimes.py create mode 100644 backend/ohq/management/commands/course_stat.py create mode 100644 backend/ohq/management/commands/createcourse.py create mode 100644 backend/ohq/management/commands/populate.py create mode 100644 backend/ohq/management/commands/queue_daily_stat.py create mode 100644 backend/ohq/management/commands/queue_heatmap_stat.py create mode 100644 backend/ohq/migrations/0001_initial.py create mode 100644 backend/ohq/migrations/0002_auto_20200816_1727.py create mode 100644 backend/ohq/migrations/0003_auto_20200822_1116.py create mode 100644 backend/ohq/migrations/0004_auto_20200825_1344.py create mode 100644 backend/ohq/migrations/0005_auto_20201016_1702.py create mode 100644 backend/ohq/migrations/0006_auto_20210105_2000.py create mode 100644 backend/ohq/migrations/0007_announcement.py create mode 100644 backend/ohq/migrations/0008_auto_20210119_2218.py create mode 100644 backend/ohq/migrations/0009_auto_20210201_2224.py create mode 100644 backend/ohq/migrations/0010_auto_20210405_1720.py create mode 100644 backend/ohq/migrations/0010_auto_20210407_0145.py create mode 100644 backend/ohq/migrations/0011_merge_20210415_2110.py create mode 100644 backend/ohq/migrations/0012_queue_require_video_chat_url_on_questions.py create mode 100644 backend/ohq/migrations/0013_auto_20210924_2056.py create mode 100644 backend/ohq/migrations/0014_question_student_descriptor.py create mode 100644 backend/ohq/migrations/0015_question_templates.py create mode 100644 backend/ohq/migrations/0016_auto_20211008_2136.py create mode 100644 backend/ohq/migrations/0017_auto_20211031_1615.py create mode 100644 backend/ohq/migrations/0018_auto_20220125_0344.py create mode 100644 backend/ohq/migrations/0019_auto_20211114_1800.py create mode 100644 backend/ohq/migrations/__init__.py create mode 100644 backend/ohq/models.py create mode 100644 backend/ohq/pagination.py create mode 100644 backend/ohq/permissions.py create mode 100644 backend/ohq/queues.py create mode 100644 backend/ohq/routing.py create mode 100644 backend/ohq/schemas.py create mode 100644 backend/ohq/serializers.py create mode 100644 backend/ohq/sms.py create mode 100644 backend/ohq/statistics.py create mode 100644 backend/ohq/tasks.py create mode 100644 backend/ohq/urls.py create mode 100644 backend/ohq/views.py create mode 100644 backend/pyproject.toml create mode 100755 backend/scripts/asgi-run create mode 100644 backend/setup.cfg create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/ohq/__init__.py create mode 100644 backend/tests/ohq/test_backends.py create mode 100644 backend/tests/ohq/test_commands.py create mode 100644 backend/tests/ohq/test_filters.py create mode 100644 backend/tests/ohq/test_invite.py create mode 100644 backend/tests/ohq/test_live.py create mode 100644 backend/tests/ohq/test_migrations.py create mode 100644 backend/tests/ohq/test_models.py create mode 100644 backend/tests/ohq/test_permissions.py create mode 100644 backend/tests/ohq/test_queues.py create mode 100644 backend/tests/ohq/test_serializers.py create mode 100644 backend/tests/ohq/test_sms.py create mode 100644 backend/tests/ohq/test_tasks.py create mode 100644 backend/tests/ohq/test_views.py diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 00000000..e609407b --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,17 @@ +# Docker +Dockerfile +.dockerignore + +# git +.circleci +.git +.gitignore +.gitmodules +**/*.md +LICENSE + +# Misc +.coverage +**/__pycache__/ +tests/ +postgres/ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..e8546442 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,21 @@ +FROM pennlabs/django-base:a142aa6975ee293bbc8a09ef0b81998ce7063dd3 + +LABEL maintainer="Penn Labs" + +# Copy project dependencies +COPY Pipfile* /app/ + +# Install project dependencies +RUN pipenv install --system + +# Copy project files +COPY . /app/ + +ENV DJANGO_SETTINGS_MODULE officehoursqueue.settings.production +ENV SECRET_KEY 'temporary key just to build the docker image' + +# Copy custom asgi-run +COPY ./scripts/asgi-run /usr/local/bin/ + +# Collect static files +RUN python3 /app/manage.py collectstatic --noinput diff --git a/backend/Pipfile b/backend/Pipfile new file mode 100644 index 00000000..73707c03 --- /dev/null +++ b/backend/Pipfile @@ -0,0 +1,49 @@ +[[source]] +name = "pypi" +url = "https://pypi.org/simple" +verify_ssl = true + +[dev-packages] +codecov = "*" +black = "==19.10b0" +unittest-xml-reporting = "*" +flake8 = "*" +flake8-absolute-import = "*" +flake8-isort = "*" +flake8-quotes = "*" +django-debug-toolbar = "*" +django-extensions = "*" +parameterized = "*" +tblib = "*" + +[packages] +dj-database-url = "*" +djangorestframework = "*" +psycopg2 = "*" +sentry-sdk = "*" +django = "==3.1.7" +django-cors-headers = "*" +pyyaml = "*" +uritemplate = "*" +uwsgi = "*" +django-labs-accounts = "==0.7.1" +django-phonenumber-field = {extras = ["phonenumbers"],version = "*"} +drf-nested-routers = "*" +django-email-tools = "*" +twilio = "*" +djangorestframework-camel-case = "*" +django-filter = "*" +celery = "*" +redis = "*" +django-auto-prefetching = "*" +django-rest-live = ">=0.7.0" +channels = "<3" +channels-redis = "*" +uvicorn = {extras = ["standard"],version = "*"} +gunicorn = "*" +django-scheduler = "*" +typing-extensions = "*" +drf-excel = "*" + +[requires] +python_version = "3" diff --git a/backend/Pipfile.lock b/backend/Pipfile.lock new file mode 100644 index 00000000..c3d01fed --- /dev/null +++ b/backend/Pipfile.lock @@ -0,0 +1,1621 @@ +{ + "_meta": { + "hash": { + "sha256": "1284b5f2733885bd690e9557203ed07c65e674a13d4a23ccad77534e0ded9b9a" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "aioredis": { + "hashes": [ + "sha256:15f8af30b044c771aee6787e5ec24694c048184c7b9e54c3b60c750a4b93273a", + "sha256:b61808d7e97b7cd5a92ed574937a079c9387fdadd22bfbfa7ad2fd319ecc26e3" + ], + "version": "==1.3.1" + }, + "amqp": { + "hashes": [ + "sha256:446b3e8a8ebc2ceafd424ffcaab1c353830d48161256578ed7a65448e601ebed", + "sha256:a575f4fa659a2290dc369b000cff5fea5c6be05fe3f2d5e511bcf56c7881c3ef" + ], + "markers": "python_full_version >= '3.6.0'", + "version": "==5.1.0" + }, + "anyio": { + "hashes": [ + "sha256:a0aeffe2fb1fdf374a8e4b471444f0f3ac4fb9f5a5b542b48824475e0042a5a6", + "sha256:b5fa16c5ff93fa1046f2eeb5bbff2dad4d3514d6cda61d02816dba34fa8c3c2e" + ], + "markers": "python_full_version >= '3.6.2'", + "version": "==3.5.0" + }, + "asgiref": { + "hashes": [ + "sha256:2f8abc20f7248433085eda803936d98992f1343ddb022065779f37c5da0181d0", + "sha256:88d59c13d634dcffe0510be048210188edd79aeccb6a6c9028cdad6f31d730a9" + ], + "markers": "python_version >= '3.7'", + "version": "==3.5.0" + }, + "async-timeout": { + "hashes": [ + "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15", + "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c" + ], + "markers": "python_full_version >= '3.6.0'", + "version": "==4.0.2" + }, + "attrs": { + "hashes": [ + "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4", + "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==21.4.0" + }, + "autobahn": { + "hashes": [ + "sha256:60e1f4c602aacd052ffe3d46ae40b6b75f8286b3c46922c213b523162e58c17e" + ], + "markers": "python_version >= '3.7'", + "version": "==22.2.2" + }, + "automat": { + "hashes": [ + "sha256:7979803c74610e11ef0c0d68a2942b152df52da55336e0c9d58daf1831cbdf33", + "sha256:b6feb6455337df834f6c9962d6ccf771515b7d939bca142b29c20c2376bc6111" + ], + "version": "==20.2.0" + }, + "beautifulsoup4": { + "hashes": [ + "sha256:9a315ce70049920ea4572a4055bc4bd700c940521d36fc858205ad4fcde149bf", + "sha256:c23ad23c521d818955a4151a67d81580319d4bf548d3d49f4223ae041ff98891" + ], + "markers": "python_version >= '3.1'", + "version": "==4.10.0" + }, + "billiard": { + "hashes": [ + "sha256:299de5a8da28a783d51b197d496bef4f1595dd023a93a4f59dde1886ae905547", + "sha256:87103ea78fa6ab4d5c751c4909bcff74617d985de7fa8b672cf8618afd5a875b" + ], + "version": "==3.6.4.0" + }, + "cached-property": { + "hashes": [ + "sha256:9fa5755838eecbb2d234c3aa390bd80fbd3ac6b6869109bfc1b499f7bd89a130", + "sha256:df4f613cf7ad9a588cc381aaf4a512d26265ecebd5eb9e1ba12f1319eb85a6a0" + ], + "markers": "python_version < '3.8'", + "version": "==1.5.2" + }, + "celery": { + "hashes": [ + "sha256:8aacd02fc23a02760686d63dde1eb0daa9f594e735e73ea8fb15c2ff15cb608c", + "sha256:e2cd41667ad97d4f6a2f4672d1c6a6ebada194c619253058b5f23704aaadaa82" + ], + "index": "pypi", + "version": "==5.2.3" + }, + "certifi": { + "hashes": [ + "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872", + "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569" + ], + "version": "==2021.10.8" + }, + "cffi": { + "hashes": [ + "sha256:00c878c90cb53ccfaae6b8bc18ad05d2036553e6d9d1d9dbcf323bbe83854ca3", + "sha256:0104fb5ae2391d46a4cb082abdd5c69ea4eab79d8d44eaaf79f1b1fd806ee4c2", + "sha256:06c48159c1abed75c2e721b1715c379fa3200c7784271b3c46df01383b593636", + "sha256:0808014eb713677ec1292301ea4c81ad277b6cdf2fdd90fd540af98c0b101d20", + "sha256:10dffb601ccfb65262a27233ac273d552ddc4d8ae1bf93b21c94b8511bffe728", + "sha256:14cd121ea63ecdae71efa69c15c5543a4b5fbcd0bbe2aad864baca0063cecf27", + "sha256:17771976e82e9f94976180f76468546834d22a7cc404b17c22df2a2c81db0c66", + "sha256:181dee03b1170ff1969489acf1c26533710231c58f95534e3edac87fff06c443", + "sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0", + "sha256:263cc3d821c4ab2213cbe8cd8b355a7f72a8324577dc865ef98487c1aeee2bc7", + "sha256:2756c88cbb94231c7a147402476be2c4df2f6078099a6f4a480d239a8817ae39", + "sha256:27c219baf94952ae9d50ec19651a687b826792055353d07648a5695413e0c605", + "sha256:2a23af14f408d53d5e6cd4e3d9a24ff9e05906ad574822a10563efcef137979a", + "sha256:31fb708d9d7c3f49a60f04cf5b119aeefe5644daba1cd2a0fe389b674fd1de37", + "sha256:3415c89f9204ee60cd09b235810be700e993e343a408693e80ce7f6a40108029", + "sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139", + "sha256:3b96a311ac60a3f6be21d2572e46ce67f09abcf4d09344c49274eb9e0bf345fc", + "sha256:3f7d084648d77af029acb79a0ff49a0ad7e9d09057a9bf46596dac9514dc07df", + "sha256:41d45de54cd277a7878919867c0f08b0cf817605e4eb94093e7516505d3c8d14", + "sha256:4238e6dab5d6a8ba812de994bbb0a79bddbdf80994e4ce802b6f6f3142fcc880", + "sha256:45db3a33139e9c8f7c09234b5784a5e33d31fd6907800b316decad50af323ff2", + "sha256:45e8636704eacc432a206ac7345a5d3d2c62d95a507ec70d62f23cd91770482a", + "sha256:4958391dbd6249d7ad855b9ca88fae690783a6be9e86df65865058ed81fc860e", + "sha256:4a306fa632e8f0928956a41fa8e1d6243c71e7eb59ffbd165fc0b41e316b2474", + "sha256:57e9ac9ccc3101fac9d6014fba037473e4358ef4e89f8e181f8951a2c0162024", + "sha256:59888172256cac5629e60e72e86598027aca6bf01fa2465bdb676d37636573e8", + "sha256:5e069f72d497312b24fcc02073d70cb989045d1c91cbd53979366077959933e0", + "sha256:64d4ec9f448dfe041705426000cc13e34e6e5bb13736e9fd62e34a0b0c41566e", + "sha256:6dc2737a3674b3e344847c8686cf29e500584ccad76204efea14f451d4cc669a", + "sha256:74fdfdbfdc48d3f47148976f49fab3251e550a8720bebc99bf1483f5bfb5db3e", + "sha256:75e4024375654472cc27e91cbe9eaa08567f7fbdf822638be2814ce059f58032", + "sha256:786902fb9ba7433aae840e0ed609f45c7bcd4e225ebb9c753aa39725bb3e6ad6", + "sha256:8b6c2ea03845c9f501ed1313e78de148cd3f6cad741a75d43a29b43da27f2e1e", + "sha256:91d77d2a782be4274da750752bb1650a97bfd8f291022b379bb8e01c66b4e96b", + "sha256:91ec59c33514b7c7559a6acda53bbfe1b283949c34fe7440bcf917f96ac0723e", + "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954", + "sha256:a5263e363c27b653a90078143adb3d076c1a748ec9ecc78ea2fb916f9b861962", + "sha256:abb9a20a72ac4e0fdb50dae135ba5e77880518e742077ced47eb1499e29a443c", + "sha256:c2051981a968d7de9dd2d7b87bcb9c939c74a34626a6e2f8181455dd49ed69e4", + "sha256:c21c9e3896c23007803a875460fb786118f0cdd4434359577ea25eb556e34c55", + "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962", + "sha256:d4d692a89c5cf08a8557fdeb329b82e7bf609aadfaed6c0d79f5a449a3c7c023", + "sha256:da5db4e883f1ce37f55c667e5c0de439df76ac4cb55964655906306918e7363c", + "sha256:e7022a66d9b55e93e1a845d8c9eba2a1bebd4966cd8bfc25d9cd07d515b33fa6", + "sha256:ef1f279350da2c586a69d32fc8733092fd32cc8ac95139a00377841f59a3f8d8", + "sha256:f54a64f8b0c8ff0b64d18aa76675262e1700f3995182267998c31ae974fbc382", + "sha256:f5c7150ad32ba43a07c4479f40241756145a1f03b43480e058cfd862bf5041c7", + "sha256:f6f824dc3bce0edab5f427efcfb1d63ee75b6fcb7282900ccaf925be84efb0fc", + "sha256:fd8a250edc26254fe5b33be00402e6d287f562b6a5b2152dec302fa15bb3e997", + "sha256:ffaa5c925128e29efbde7301d8ecaf35c8c60ffbcd6a1ffd3a552177c8e5e796" + ], + "version": "==1.15.0" + }, + "channels": { + "hashes": [ + "sha256:08e756406d7165cb32f6fc3090c0643f41ca9f7e0f7fada0b31194662f20f414", + "sha256:80a5ad1962ae039a3dcc0a5cb5212413e66e2f11ad9e9db8004834436daf3400" + ], + "index": "pypi", + "version": "==2.4.0" + }, + "channels-redis": { + "hashes": [ + "sha256:5dffd4cc16174125bd4043fc8fe7462ca7403cf801d59a9fa7410ed101fa6a57", + "sha256:6e4565b7c11c6bcde5d48556cb83bd043779697ff03811867d2f895aa6170d56" + ], + "index": "pypi", + "version": "==3.4.0" + }, + "charset-normalizer": { + "hashes": [ + "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597", + "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df" + ], + "markers": "python_version >= '3'", + "version": "==2.0.12" + }, + "click": { + "hashes": [ + "sha256:6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1", + "sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb" + ], + "markers": "python_full_version >= '3.6.0'", + "version": "==8.0.4" + }, + "click-didyoumean": { + "hashes": [ + "sha256:a0713dc7a1de3f06bc0df5a9567ad19ead2d3d5689b434768a6145bff77c0667", + "sha256:f184f0d851d96b6d29297354ed981b7dd71df7ff500d82fa6d11f0856bee8035" + ], + "markers": "python_version < '4' and python_full_version >= '3.6.2'", + "version": "==0.3.0" + }, + "click-plugins": { + "hashes": [ + "sha256:46ab999744a9d831159c3411bb0c79346d94a444df9a3a3742e9ed63645f264b", + "sha256:5d262006d3222f5057fd81e1623d4443e41dcda5dc815c06b442aa3c02889fc8" + ], + "version": "==1.1.1" + }, + "click-repl": { + "hashes": [ + "sha256:94b3fbbc9406a236f176e0506524b2937e4b23b6f4c0c0b2a0a83f8a64e9194b", + "sha256:cd12f68d745bf6151210790540b4cb064c7b13e571bc64b6957d98d120dacfd8" + ], + "version": "==0.2.0" + }, + "constantly": { + "hashes": [ + "sha256:586372eb92059873e29eba4f9dec8381541b4d3834660707faf8ba59146dfc35", + "sha256:dd2fa9d6b1a51a83f0d7dd76293d734046aa176e384bf6e33b7e44880eb37c5d" + ], + "version": "==15.1.0" + }, + "cryptography": { + "hashes": [ + "sha256:0a3bf09bb0b7a2c93ce7b98cb107e9170a90c51a0162a20af1c61c765b90e60b", + "sha256:1f64a62b3b75e4005df19d3b5235abd43fa6358d5516cfc43d87aeba8d08dd51", + "sha256:32db5cc49c73f39aac27574522cecd0a4bb7384e71198bc65a0d23f901e89bb7", + "sha256:4881d09298cd0b669bb15b9cfe6166f16fc1277b4ed0d04a22f3d6430cb30f1d", + "sha256:4e2dddd38a5ba733be6a025a1475a9f45e4e41139d1321f412c6b360b19070b6", + "sha256:53e0285b49fd0ab6e604f4c5d9c5ddd98de77018542e88366923f152dbeb3c29", + "sha256:70f8f4f7bb2ac9f340655cbac89d68c527af5bb4387522a8413e841e3e6628c9", + "sha256:7b2d54e787a884ffc6e187262823b6feb06c338084bbe80d45166a1cb1c6c5bf", + "sha256:7be666cc4599b415f320839e36367b273db8501127b38316f3b9f22f17a0b815", + "sha256:8241cac0aae90b82d6b5c443b853723bcc66963970c67e56e71a2609dc4b5eaf", + "sha256:82740818f2f240a5da8dfb8943b360e4f24022b093207160c77cadade47d7c85", + "sha256:8897b7b7ec077c819187a123174b645eb680c13df68354ed99f9b40a50898f77", + "sha256:c2c5250ff0d36fd58550252f54915776940e4e866f38f3a7866d92b32a654b86", + "sha256:ca9f686517ec2c4a4ce930207f75c00bf03d94e5063cbc00a1dc42531511b7eb", + "sha256:d2b3d199647468d410994dbeb8cec5816fb74feb9368aedf300af709ef507e3e", + "sha256:da73d095f8590ad437cd5e9faf6628a218aa7c387e1fdf67b888b47ba56a17f0", + "sha256:e167b6b710c7f7bc54e67ef593f8731e1f45aa35f8a8a7b72d6e42ec76afd4b3", + "sha256:ea634401ca02367c1567f012317502ef3437522e2fc44a3ea1844de028fa4b84", + "sha256:ec6597aa85ce03f3e507566b8bcdf9da2227ec86c4266bd5e6ab4d9e0cc8dab2", + "sha256:f64b232348ee82f13aac22856515ce0195837f6968aeaa94a3d0353ea2ec06a6" + ], + "markers": "python_full_version >= '3.6.0'", + "version": "==36.0.2" + }, + "daphne": { + "hashes": [ + "sha256:1ca46d7419103958bbc9576fb7ba3b25b053006e22058bc97084ee1a7d44f4ba", + "sha256:aa64840015709bbc9daa3c4464a4a4d437937d6cda10a9b51e913eb319272553" + ], + "version": "==2.5.0" + }, + "deprecated": { + "hashes": [ + "sha256:43ac5335da90c31c24ba028af536a91d41d53f9e6901ddb021bcc572ce44e38d", + "sha256:64756e3e14c8c5eea9795d93c524551432a0be75629f8f29e67ab8caf076c76d" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.2.13" + }, + "dj-database-url": { + "hashes": [ + "sha256:4aeaeb1f573c74835b0686a2b46b85990571159ffc21aa57ecd4d1e1cb334163", + "sha256:851785365761ebe4994a921b433062309eb882fedd318e1b0fcecc607ed02da9" + ], + "index": "pypi", + "version": "==0.5.0" + }, + "django": { + "hashes": [ + "sha256:32ce792ee9b6a0cbbec340123e229ac9f765dff8c2a4ae9247a14b2ba3a365a7", + "sha256:baf099db36ad31f970775d0be5587cc58a6256a6771a44eb795b554d45f211b8" + ], + "index": "pypi", + "version": "==3.1.7" + }, + "django-auto-prefetching": { + "hashes": [ + "sha256:93ff54855a81782f370491ba23ab1b7a885b152fcfee0938d6ba97003425e5af", + "sha256:fb79cfa68506fc219f40303344e11d6b4fb852db7620fe0d8322cbb60175018e" + ], + "index": "pypi", + "version": "==0.2.11" + }, + "django-cors-headers": { + "hashes": [ + "sha256:a22be2befd4069c4fc174f11cf067351df5c061a3a5f94a01650b4e928b0372b", + "sha256:eb98389bf7a2afc5d374806af4a9149697e3a6955b5a2dc2bf049f7d33647456" + ], + "index": "pypi", + "version": "==3.11.0" + }, + "django-email-tools": { + "hashes": [ + "sha256:08781bbb6a828352489d5945a734c0143b80b61160ecbf079f95c272a9a93437", + "sha256:2d14ab510dcb3f9b46865f6f55dcc171ff49b2e0406b5cf7a66efd58ee670aac" + ], + "index": "pypi", + "version": "==0.1.1" + }, + "django-filter": { + "hashes": [ + "sha256:632a251fa8f1aadb4b8cceff932bb52fe2f826dd7dfe7f3eac40e5c463d6836e", + "sha256:f4a6737a30104c98d2e2a5fb93043f36dd7978e0c7ddc92f5998e85433ea5063" + ], + "index": "pypi", + "version": "==21.1" + }, + "django-labs-accounts": { + "hashes": [ + "sha256:34ab79e9399f23c1d71d8468ed5602e1ac8ad370a2ae47bdd7e59f782bb407c9", + "sha256:4b0ab092773b00f4e8335f8310381c9d66aafb1d37d7b4aaa22b76339a6f0af0" + ], + "index": "pypi", + "version": "==0.7.1" + }, + "django-phonenumber-field": { + "extras": [ + "phonenumbers" + ], + "hashes": [ + "sha256:897b902a1654b0eb21f6268498a3359e2c4eb90af9585cb8693af186ede8c5bb", + "sha256:b1ff950f90a8911ff323ccf77c8f6fe4299a9f671fa61c8734a6994359f07446" + ], + "index": "pypi", + "version": "==6.1.0" + }, + "django-rest-live": { + "hashes": [ + "sha256:28536aeb05a45fcc99c16b0b0a1594bd3d42e65983e6e3b3feb0393a54db1038", + "sha256:b3cc867a469700437e48960f6c20305e56a6d16afadc6425f74083e18a992d67" + ], + "index": "pypi", + "version": "==0.7.0" + }, + "django-scheduler": { + "hashes": [ + "sha256:474cfcc6879006e9168718ab433937f370644d046652aa56264b77af22724286", + "sha256:a14c6320a849187b3cc27c69c00b6575f8f6a6ff7e0fff4f2de3f6179d6d0fdd" + ], + "index": "pypi", + "version": "==0.9.5" + }, + "djangorestframework": { + "hashes": [ + "sha256:0c33407ce23acc68eca2a6e46424b008c9c02eceb8cf18581921d0092bc1f2ee", + "sha256:24c4bf58ed7e85d1fe4ba250ab2da926d263cd57d64b03e8dcef0ac683f8b1aa" + ], + "index": "pypi", + "version": "==3.13.1" + }, + "djangorestframework-camel-case": { + "hashes": [ + "sha256:df591362ffa448c8f0a354c56ae8a53fb7abbb15e222951d0c6f5f781633907e" + ], + "index": "pypi", + "version": "==1.3.0" + }, + "drf-excel": { + "hashes": [ + "sha256:7326aff0a4be66e52cb2ad9291952c0ffcfcb651b70ab85c52c656c72e63bb44", + "sha256:d0c3e890f1d18bd7ac70c8c35ea759df85246774b9691d7b0acbdd08372554a3" + ], + "index": "pypi", + "version": "==2.1.0" + }, + "drf-nested-routers": { + "hashes": [ + "sha256:01aa556b8c08608bb74fb34f6ca065a5183f2cda4dc0478192cc17a2581d71b0", + "sha256:996b77f3f4dfaf64569e7b8f04e3919945f90f95366838ca5b8bed9dd709d6c5" + ], + "index": "pypi", + "version": "==0.93.4" + }, + "et-xmlfile": { + "hashes": [ + "sha256:8eb9e2bc2f8c97e37a2dc85a09ecdcdec9d8a396530a6d5a33b30b9a92da0c5c", + "sha256:a2ba85d1d6a74ef63837eed693bcb89c3f752169b0e3e7ae5b16ca5e1b3deada" + ], + "markers": "python_full_version >= '3.6.0'", + "version": "==1.1.0" + }, + "gunicorn": { + "hashes": [ + "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e", + "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8" + ], + "index": "pypi", + "version": "==20.1.0" + }, + "h11": { + "hashes": [ + "sha256:70813c1135087a248a4d38cc0e1a0181ffab2188141a93eaf567940c3957ff06", + "sha256:8ddd78563b633ca55346c8cd41ec0af27d3c79931828beffb46ce70a379e7442" + ], + "markers": "python_full_version >= '3.6.0'", + "version": "==0.13.0" + }, + "hiredis": { + "hashes": [ + "sha256:04026461eae67fdefa1949b7332e488224eac9e8f2b5c58c98b54d29af22093e", + "sha256:04927a4c651a0e9ec11c68e4427d917e44ff101f761cd3b5bc76f86aaa431d27", + "sha256:07bbf9bdcb82239f319b1f09e8ef4bdfaec50ed7d7ea51a56438f39193271163", + "sha256:09004096e953d7ebd508cded79f6b21e05dff5d7361771f59269425108e703bc", + "sha256:0adea425b764a08270820531ec2218d0508f8ae15a448568109ffcae050fee26", + "sha256:0b39ec237459922c6544d071cdcf92cbb5bc6685a30e7c6d985d8a3e3a75326e", + "sha256:0d5109337e1db373a892fdcf78eb145ffb6bbd66bb51989ec36117b9f7f9b579", + "sha256:0f41827028901814c709e744060843c77e78a3aca1e0d6875d2562372fcb405a", + "sha256:11d119507bb54e81f375e638225a2c057dda748f2b1deef05c2b1a5d42686048", + "sha256:1233e303645f468e399ec906b6b48ab7cd8391aae2d08daadbb5cad6ace4bd87", + "sha256:139705ce59d94eef2ceae9fd2ad58710b02aee91e7fa0ccb485665ca0ecbec63", + "sha256:1f03d4dadd595f7a69a75709bc81902673fa31964c75f93af74feac2f134cc54", + "sha256:240ce6dc19835971f38caf94b5738092cb1e641f8150a9ef9251b7825506cb05", + "sha256:294a6697dfa41a8cba4c365dd3715abc54d29a86a40ec6405d677ca853307cfb", + "sha256:3d55e36715ff06cdc0ab62f9591607c4324297b6b6ce5b58cb9928b3defe30ea", + "sha256:3dddf681284fe16d047d3ad37415b2e9ccdc6c8986c8062dbe51ab9a358b50a5", + "sha256:3f5f7e3a4ab824e3de1e1700f05ad76ee465f5f11f5db61c4b297ec29e692b2e", + "sha256:508999bec4422e646b05c95c598b64bdbef1edf0d2b715450a078ba21b385bcc", + "sha256:5d2a48c80cf5a338d58aae3c16872f4d452345e18350143b3bf7216d33ba7b99", + "sha256:5dc7a94bb11096bc4bffd41a3c4f2b958257085c01522aa81140c68b8bf1630a", + "sha256:65d653df249a2f95673976e4e9dd7ce10de61cfc6e64fa7eeaa6891a9559c581", + "sha256:7492af15f71f75ee93d2a618ca53fea8be85e7b625e323315169977fae752426", + "sha256:7f0055f1809b911ab347a25d786deff5e10e9cf083c3c3fd2dd04e8612e8d9db", + "sha256:807b3096205c7cec861c8803a6738e33ed86c9aae76cac0e19454245a6bbbc0a", + "sha256:81d6d8e39695f2c37954d1011c0480ef7cf444d4e3ae24bc5e89ee5de360139a", + "sha256:87c7c10d186f1743a8fd6a971ab6525d60abd5d5d200f31e073cd5e94d7e7a9d", + "sha256:8b42c0dc927b8d7c0eb59f97e6e34408e53bc489f9f90e66e568f329bff3e443", + "sha256:a00514362df15af041cc06e97aebabf2895e0a7c42c83c21894be12b84402d79", + "sha256:a39efc3ade8c1fb27c097fd112baf09d7fd70b8cb10ef1de4da6efbe066d381d", + "sha256:a4ee8000454ad4486fb9f28b0cab7fa1cd796fc36d639882d0b34109b5b3aec9", + "sha256:a7928283143a401e72a4fad43ecc85b35c27ae699cf5d54d39e1e72d97460e1d", + "sha256:adf4dd19d8875ac147bf926c727215a0faf21490b22c053db464e0bf0deb0485", + "sha256:ae8427a5e9062ba66fc2c62fb19a72276cf12c780e8db2b0956ea909c48acff5", + "sha256:b4c8b0bc5841e578d5fb32a16e0c305359b987b850a06964bd5a62739d688048", + "sha256:b84f29971f0ad4adaee391c6364e6f780d5aae7e9226d41964b26b49376071d0", + "sha256:c39c46d9e44447181cd502a35aad2bb178dbf1b1f86cf4db639d7b9614f837c6", + "sha256:cb2126603091902767d96bcb74093bd8b14982f41809f85c9b96e519c7e1dc41", + "sha256:dcef843f8de4e2ff5e35e96ec2a4abbdf403bd0f732ead127bd27e51f38ac298", + "sha256:e3447d9e074abf0e3cd85aef8131e01ab93f9f0e86654db7ac8a3f73c63706ce", + "sha256:f52010e0a44e3d8530437e7da38d11fb822acfb0d5b12e9cd5ba655509937ca0", + "sha256:f8196f739092a78e4f6b1b2172679ed3343c39c61a3e9d722ce6fcf1dac2824a" + ], + "markers": "python_full_version >= '3.6.0'", + "version": "==2.0.0" + }, + "httptools": { + "hashes": [ + "sha256:1a99346ebcb801b213c591540837340bdf6fd060a8687518d01c607d338b7424", + "sha256:1ee0b459257e222b878a6c09ccf233957d3a4dcb883b0847640af98d2d9aac23", + "sha256:20a45bcf22452a10fa8d58b7dbdb474381f6946bf5b8933e3662d572bc61bae4", + "sha256:29bf97a5c532da9c7a04de2c7a9c31d1d54f3abd65a464119b680206bbbb1055", + "sha256:2c9a930c378b3d15d6b695fb95ebcff81a7395b4f9775c4f10a076beb0b2c1ff", + "sha256:2db44a0b294d317199e9f80123e72c6b005c55b625b57fae36de68670090fa48", + "sha256:3194f6d6443befa8d4db16c1946b2fc428a3ceb8ab32eb6f09a59f86104dc1a0", + "sha256:34d2903dd2a3dd85d33705b6fde40bf91fc44411661283763fd0746723963c83", + "sha256:48e48530d9b995a84d1d89ae6b3ec4e59ea7d494b150ac3bbc5e2ac4acce92cd", + "sha256:54bbd295f031b866b9799dd39cb45deee81aca036c9bff9f58ca06726f6494f1", + "sha256:5d1fe6b6661022fd6cac541f54a4237496b246e6f1c0a6b41998ee08a1135afe", + "sha256:645373c070080e632480a3d251d892cb795be3d3a15f86975d0f1aca56fd230d", + "sha256:6a1a7dfc1f9c78a833e2c4904757a0f47ce25d08634dd2a52af394eefe5f9777", + "sha256:701e66b59dd21a32a274771238025d58db7e2b6ecebbab64ceff51b8e31527ae", + "sha256:72aa3fbe636b16d22e04b5a9d24711b043495e0ecfe58080addf23a1a37f3409", + "sha256:7af6bdbd21a2a25d6784f6d67f44f5df33ef39b6159543b9f9064d365c01f919", + "sha256:7ee9f226acab9085037582c059d66769862706e8e8cd2340470ceb8b3850873d", + "sha256:7f7bfb74718f52d5ed47d608d507bf66d3bc01d4a8b3e6dd7134daaae129357b", + "sha256:8e2eb957787cbb614a0f006bfc5798ff1d90ac7c4dd24854c84edbdc8c02369e", + "sha256:903f739c9fb78dab8970b0f3ea51f21955b24b45afa77b22ff0e172fc11ef111", + "sha256:98993805f1e3cdb53de4eed02b55dcc953cdf017ba7bbb2fd89226c086a6d855", + "sha256:9967d9758df505975913304c434cb9ab21e2c609ad859eb921f2f615a038c8de", + "sha256:a113789e53ac1fa26edf99856a61e4c493868e125ae0dd6354cf518948fbbd5c", + "sha256:a522d12e2ddbc2e91842ffb454a1aeb0d47607972c7d8fc88bd0838d97fb8a2a", + "sha256:abe829275cdd4174b4c4e65ad718715d449e308d59793bf3a931ee1bf7e7b86c", + "sha256:c286985b5e194ca0ebb2908d71464b9be8f17cc66d6d3e330e8d5407248f56ad", + "sha256:cd1295f52971097f757edfbfce827b6dbbfb0f7a74901ee7d4933dff5ad4c9af", + "sha256:ceafd5e960b39c7e0d160a1936b68eb87c5e79b3979d66e774f0c77d4d8faaed", + "sha256:d1f27bb0f75bef722d6e22dc609612bfa2f994541621cd2163f8c943b6463dfe", + "sha256:d3a4e165ca6204f34856b765d515d558dc84f1352033b8721e8d06c3e44930c3", + "sha256:d9b90bf58f3ba04e60321a23a8723a1ff2a9377502535e70495e5ada8e6e6722", + "sha256:f72b5d24d6730035128b238decdc4c0f2104b7056a7ca55cf047c106842ec890", + "sha256:fcddfe70553be717d9745990dfdb194e22ee0f60eb8f48c0794e7bfeda30d2d5", + "sha256:fdb9f9ed79bc6f46b021b3319184699ba1a22410a82204e6e89c774530069683" + ], + "version": "==0.4.0" + }, + "hyperlink": { + "hashes": [ + "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b", + "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4" + ], + "version": "==21.0.0" + }, + "icalendar": { + "hashes": [ + "sha256:cc73fa9c848744843046228cb66ea86cd8c18d73a51b140f7c003f760b84a997", + "sha256:cf1446ffdf1b6ad469451a8966cfa7694f5fac796ac6fc7cd93e28c51a637d2c" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==4.0.9" + }, + "idna": { + "hashes": [ + "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", + "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" + ], + "version": "==3.3" + }, + "importlib-metadata": { + "hashes": [ + "sha256:1208431ca90a8cca1a6b8af391bb53c1a2db74e5d1cef6ddced95d4b2062edc6", + "sha256:ea4c597ebf37142f827b8f39299579e31685c31d3a438b59f469406afd0f2539" + ], + "markers": "python_version < '3.8'", + "version": "==4.11.3" + }, + "incremental": { + "hashes": [ + "sha256:02f5de5aff48f6b9f665d99d48bfc7ec03b6e3943210de7cfc88856d755d6f57", + "sha256:92014aebc6a20b78a8084cdd5645eeaa7f74b8933f70fa3ada2cfbd1e3b54321" + ], + "version": "==21.3.0" + }, + "kombu": { + "hashes": [ + "sha256:37cee3ee725f94ea8bb173eaab7c1760203ea53bbebae226328600f9d2799610", + "sha256:8b213b24293d3417bcf0d2f5537b7f756079e3ea232a8386dcc89a59fd2361a4" + ], + "markers": "python_version >= '3.7'", + "version": "==5.2.4" + }, + "msgpack": { + "hashes": [ + "sha256:0d8c332f53ffff01953ad25131272506500b14750c1d0ce8614b17d098252fbc", + "sha256:1c58cdec1cb5fcea8c2f1771d7b5fec79307d056874f746690bd2bdd609ab147", + "sha256:2c3ca57c96c8e69c1a0d2926a6acf2d9a522b41dc4253a8945c4c6cd4981a4e3", + "sha256:2f30dd0dc4dfe6231ad253b6f9f7128ac3202ae49edd3f10d311adc358772dba", + "sha256:2f97c0f35b3b096a330bb4a1a9247d0bd7e1f3a2eba7ab69795501504b1c2c39", + "sha256:36a64a10b16c2ab31dcd5f32d9787ed41fe68ab23dd66957ca2826c7f10d0b85", + "sha256:3d875631ecab42f65f9dce6f55ce6d736696ced240f2634633188de2f5f21af9", + "sha256:40fb89b4625d12d6027a19f4df18a4de5c64f6f3314325049f219683e07e678a", + "sha256:47d733a15ade190540c703de209ffbc42a3367600421b62ac0c09fde594da6ec", + "sha256:494471d65b25a8751d19c83f1a482fd411d7ca7a3b9e17d25980a74075ba0e88", + "sha256:51fdc7fb93615286428ee7758cecc2f374d5ff363bdd884c7ea622a7a327a81e", + "sha256:6eef0cf8db3857b2b556213d97dd82de76e28a6524853a9beb3264983391dc1a", + "sha256:6f4c22717c74d44bcd7af353024ce71c6b55346dad5e2cc1ddc17ce8c4507c6b", + "sha256:73a80bd6eb6bcb338c1ec0da273f87420829c266379c8c82fa14c23fb586cfa1", + "sha256:89908aea5f46ee1474cc37fbc146677f8529ac99201bc2faf4ef8edc023c2bf3", + "sha256:8a3a5c4b16e9d0edb823fe54b59b5660cc8d4782d7bf2c214cb4b91a1940a8ef", + "sha256:96acc674bb9c9be63fa8b6dabc3248fdc575c4adc005c440ad02f87ca7edd079", + "sha256:973ad69fd7e31159eae8f580f3f707b718b61141838321c6fa4d891c4a2cca52", + "sha256:9b6f2d714c506e79cbead331de9aae6837c8dd36190d02da74cb409b36162e8a", + "sha256:9c0903bd93cbd34653dd63bbfcb99d7539c372795201f39d16fdfde4418de43a", + "sha256:9fce00156e79af37bb6db4e7587b30d11e7ac6a02cb5bac387f023808cd7d7f4", + "sha256:a598d0685e4ae07a0672b59792d2cc767d09d7a7f39fd9bd37ff84e060b1a996", + "sha256:b0a792c091bac433dfe0a70ac17fc2087d4595ab835b47b89defc8bbabcf5c73", + "sha256:bb87f23ae7d14b7b3c21009c4b1705ec107cb21ee71975992f6aca571fb4a42a", + "sha256:bf1e6bfed4860d72106f4e0a1ab519546982b45689937b40257cfd820650b920", + "sha256:c1ba333b4024c17c7591f0f372e2daa3c31db495a9b2af3cf664aef3c14354f7", + "sha256:c2140cf7a3ec475ef0938edb6eb363fa704159e0bf71dde15d953bacc1cf9d7d", + "sha256:c7e03b06f2982aa98d4ddd082a210c3db200471da523f9ac197f2828e80e7770", + "sha256:d02cea2252abc3756b2ac31f781f7a98e89ff9759b2e7450a1c7a0d13302ff50", + "sha256:da24375ab4c50e5b7486c115a3198d207954fe10aaa5708f7b65105df09109b2", + "sha256:e4c309a68cb5d6bbd0c50d5c71a25ae81f268c2dc675c6f4ea8ab2feec2ac4e2", + "sha256:f01b26c2290cbd74316990ba84a14ac3d599af9cebefc543d241a66e785cf17d", + "sha256:f201d34dc89342fabb2a10ed7c9a9aaaed9b7af0f16a5923f1ae562b31258dea", + "sha256:f74da1e5fcf20ade12c6bf1baa17a2dc3604958922de8dc83cbe3eff22e8b611" + ], + "version": "==1.0.3" + }, + "oauthlib": { + "hashes": [ + "sha256:23a8208d75b902797ea29fd31fa80a15ed9dc2c6c16fe73f5d346f83f6fa27a2", + "sha256:6db33440354787f9b7f3a6dbd4febf5d0f93758354060e802f6c06cb493022fe" + ], + "markers": "python_full_version >= '3.6.0'", + "version": "==3.2.0" + }, + "openpyxl": { + "hashes": [ + "sha256:40f568b9829bf9e446acfffce30250ac1fa39035124d55fc024025c41481c90f", + "sha256:8f3b11bd896a95468a4ab162fc4fcd260d46157155d1f8bfaabb99d88cfcf79f" + ], + "markers": "python_full_version >= '3.6.0'", + "version": "==3.0.9" + }, + "packaging": { + "hashes": [ + "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb", + "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522" + ], + "markers": "python_full_version >= '3.6.0'", + "version": "==21.3" + }, + "phonenumbers": { + "hashes": [ + "sha256:94e30f59b2be6c4310a90f3d5da53d49900bdb440484506f3333c694ebb0cdab", + "sha256:e3af21c1e33a3dd063cddba3cad653abb8d23c37c62cedee597a3f3ea0f5365c" + ], + "version": "==8.12.45" + }, + "prompt-toolkit": { + "hashes": [ + "sha256:30129d870dcb0b3b6a53efdc9d0a83ea96162ffd28ffe077e94215b233dc670c", + "sha256:9f1cd16b1e86c2968f2519d7fb31dd9d669916f515612c269d14e9ed52b51650" + ], + "markers": "python_full_version >= '3.6.2'", + "version": "==3.0.28" + }, + "psycopg2": { + "hashes": [ + "sha256:06f32425949bd5fe8f625c49f17ebb9784e1e4fe928b7cce72edc36fb68e4c0c", + "sha256:0762c27d018edbcb2d34d51596e4346c983bd27c330218c56c4dc25ef7e819bf", + "sha256:083707a696e5e1c330af2508d8fab36f9700b26621ccbcb538abe22e15485362", + "sha256:34b33e0162cfcaad151f249c2649fd1030010c16f4bbc40a604c1cb77173dcf7", + "sha256:4295093a6ae3434d33ec6baab4ca5512a5082cc43c0505293087b8a46d108461", + "sha256:8cf3878353cc04b053822896bc4922b194792df9df2f1ad8da01fb3043602126", + "sha256:8e841d1bf3434da985cc5ef13e6f75c8981ced601fd70cc6bf33351b91562981", + "sha256:9572e08b50aed176ef6d66f15a21d823bb6f6d23152d35e8451d7d2d18fdac56", + "sha256:a81e3866f99382dfe8c15a151f1ca5fde5815fde879348fe5a9884a7c092a305", + "sha256:cb10d44e6694d763fa1078a26f7f6137d69f555a78ec85dc2ef716c37447e4b2", + "sha256:d3ca6421b942f60c008f81a3541e8faf6865a28d5a9b48544b0ee4f40cac7fca" + ], + "index": "pypi", + "version": "==2.9.3" + }, + "pyasn1": { + "hashes": [ + "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359", + "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576", + "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf", + "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7", + "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", + "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00", + "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8", + "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86", + "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12", + "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776", + "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", + "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2", + "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3" + ], + "version": "==0.4.8" + }, + "pyasn1-modules": { + "hashes": [ + "sha256:0845a5582f6a02bb3e1bde9ecfc4bfcae6ec3210dd270522fee602365430c3f8", + "sha256:0fe1b68d1e486a1ed5473f1302bd991c1611d319bba158e98b106ff86e1d7199", + "sha256:15b7c67fabc7fc240d87fb9aabf999cf82311a6d6fb2c70d00d3d0604878c811", + "sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed", + "sha256:65cebbaffc913f4fe9e4808735c95ea22d7a7775646ab690518c056784bc21b4", + "sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e", + "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74", + "sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb", + "sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45", + "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd", + "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0", + "sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d", + "sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405" + ], + "version": "==0.2.8" + }, + "pycparser": { + "hashes": [ + "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9", + "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.21" + }, + "pyjwt": { + "hashes": [ + "sha256:b888b4d56f06f6dcd777210c334e69c737be74755d3e5e9ee3fe67dc18a0ee41", + "sha256:e0c4bb8d9f0af0c7f5b1ec4c5036309617d03d56932877f2f7a0beeb5318322f" + ], + "markers": "python_full_version >= '3.6.0'", + "version": "==2.3.0" + }, + "pyopenssl": { + "hashes": [ + "sha256:660b1b1425aac4a1bea1d94168a85d99f0b3144c869dd4390d27629d0087f1bf", + "sha256:ea252b38c87425b64116f808355e8da644ef9b07e429398bfece610f893ee2e0" + ], + "version": "==22.0.0" + }, + "pyparsing": { + "hashes": [ + "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea", + "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484" + ], + "markers": "python_full_version >= '3.6.0'", + "version": "==3.0.7" + }, + "python-dateutil": { + "hashes": [ + "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", + "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.8.2" + }, + "python-dotenv": { + "hashes": [ + "sha256:32b2bdc1873fd3a3c346da1c6db83d0053c3c62f28f1f38516070c4c8971b1d3", + "sha256:a5de49a31e953b45ff2d2fd434bbc2670e8db5273606c1e737cc6b93eff3655f" + ], + "version": "==0.19.2" + }, + "pytz": { + "hashes": [ + "sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7", + "sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c" + ], + "version": "==2022.1" + }, + "pyyaml": { + "hashes": [ + "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293", + "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b", + "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57", + "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b", + "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4", + "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07", + "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba", + "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9", + "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287", + "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513", + "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0", + "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0", + "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92", + "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f", + "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2", + "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc", + "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c", + "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86", + "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4", + "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c", + "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34", + "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b", + "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c", + "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb", + "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737", + "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3", + "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d", + "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53", + "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78", + "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803", + "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a", + "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174", + "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5" + ], + "index": "pypi", + "version": "==6.0" + }, + "redis": { + "hashes": [ + "sha256:04629f8e42be942c4f7d1812f2094568f04c612865ad19ad3ace3005da70631a", + "sha256:1d9a0cdf89fdd93f84261733e24f55a7bbd413a9b219fdaf56e3e728ca9a2306" + ], + "index": "pypi", + "version": "==4.1.4" + }, + "requests": { + "hashes": [ + "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61", + "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", + "version": "==2.27.1" + }, + "requests-oauthlib": { + "hashes": [ + "sha256:2577c501a2fb8d05a304c09d090d6e47c306fef15809d102b327cf8364bddab5", + "sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.3.1" + }, + "sentry-sdk": { + "hashes": [ + "sha256:32af1a57954576709242beb8c373b3dbde346ac6bd616921def29d68846fb8c3", + "sha256:38fd16a92b5ef94203db3ece10e03bdaa291481dd7e00e77a148aa0302267d47" + ], + "index": "pypi", + "version": "==1.5.8" + }, + "service-identity": { + "hashes": [ + "sha256:6e6c6086ca271dc11b033d17c3a8bea9f24ebff920c587da090afc9519419d34", + "sha256:f0b0caac3d40627c3c04d7a51b6e06721857a0e10a8775f2d1d7e72901b3a7db" + ], + "version": "==21.1.0" + }, + "six": { + "hashes": [ + "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", + "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.16.0" + }, + "sniffio": { + "hashes": [ + "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663", + "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de" + ], + "markers": "python_version >= '3.5'", + "version": "==1.2.0" + }, + "soupsieve": { + "hashes": [ + "sha256:1a3cca2617c6b38c0343ed661b1fa5de5637f257d4fe22bd9f1338010a1efefb", + "sha256:b8d49b1cd4f037c7082a9683dfa1801aa2597fb11c3a1155b7a5b94829b4f1f9" + ], + "markers": "python_full_version >= '3.6.0'", + "version": "==2.3.1" + }, + "sqlparse": { + "hashes": [ + "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae", + "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d" + ], + "markers": "python_version >= '3.5'", + "version": "==0.4.2" + }, + "twilio": { + "hashes": [ + "sha256:d8e021c1c82f5552446d25d2b91b7373d76d712c90324470c886220853496be1", + "sha256:dedc37864ee9682631ce942a0dfbb04286fe4938ed90b1f22a583596ffbf459e" + ], + "index": "pypi", + "version": "==7.7.1" + }, + "twisted": { + "extras": [ + "tls" + ], + "hashes": [ + "sha256:57f32b1f6838facb8c004c89467840367ad38e9e535f8252091345dba500b4f2", + "sha256:5c63c149eb6b8fe1e32a0215b1cef96fabdba04f705d8efb9174b1ccf5b49d49" + ], + "markers": "python_full_version >= '3.6.7'", + "version": "==22.2.0" + }, + "txaio": { + "hashes": [ + "sha256:2e4582b70f04b2345908254684a984206c0d9b50e3074a24a4c55aba21d24d01", + "sha256:41223af4a9d5726e645a8ee82480f413e5e300dd257db94bc38ae12ea48fb2e5" + ], + "markers": "python_full_version >= '3.6.0'", + "version": "==22.2.1" + }, + "typing-extensions": { + "hashes": [ + "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42", + "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2" + ], + "index": "pypi", + "version": "==4.1.1" + }, + "uritemplate": { + "hashes": [ + "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0", + "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e" + ], + "index": "pypi", + "version": "==4.1.1" + }, + "urllib3": { + "hashes": [ + "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14", + "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", + "version": "==1.26.9" + }, + "uvicorn": { + "extras": [ + "standard" + ], + "hashes": [ + "sha256:19e2a0e96c9ac5581c01eb1a79a7d2f72bb479691acd2b8921fce48ed5b961a6", + "sha256:5180f9d059611747d841a4a4c4ab675edf54c8489e97f96d0583ee90ac3bfc23" + ], + "index": "pypi", + "version": "==0.17.6" + }, + "uvloop": { + "hashes": [ + "sha256:04ff57aa137230d8cc968f03481176041ae789308b4d5079118331ab01112450", + "sha256:089b4834fd299d82d83a25e3335372f12117a7d38525217c2258e9b9f4578897", + "sha256:1e5f2e2ff51aefe6c19ee98af12b4ae61f5be456cd24396953244a30880ad861", + "sha256:30ba9dcbd0965f5c812b7c2112a1ddf60cf904c1c160f398e7eed3a6b82dcd9c", + "sha256:3a19828c4f15687675ea912cc28bbcb48e9bb907c801873bd1519b96b04fb805", + "sha256:6224f1401025b748ffecb7a6e2652b17768f30b1a6a3f7b44660e5b5b690b12d", + "sha256:647e481940379eebd314c00440314c81ea547aa636056f554d491e40503c8464", + "sha256:6ccd57ae8db17d677e9e06192e9c9ec4bd2066b77790f9aa7dede2cc4008ee8f", + "sha256:772206116b9b57cd625c8a88f2413df2fcfd0b496eb188b82a43bed7af2c2ec9", + "sha256:8e0d26fa5875d43ddbb0d9d79a447d2ace4180d9e3239788208527c4784f7cab", + "sha256:98d117332cc9e5ea8dfdc2b28b0a23f60370d02e1395f88f40d1effd2cb86c4f", + "sha256:b572256409f194521a9895aef274cea88731d14732343da3ecdb175228881638", + "sha256:bd53f7f5db562f37cd64a3af5012df8cac2c464c97e732ed556800129505bd64", + "sha256:bd8f42ea1ea8f4e84d265769089964ddda95eb2bb38b5cbe26712b0616c3edee", + "sha256:e814ac2c6f9daf4c36eb8e85266859f42174a4ff0d71b99405ed559257750382", + "sha256:f74bc20c7b67d1c27c72601c78cf95be99d5c2cdd4514502b4f3eb0933ff1228" + ], + "version": "==0.16.0" + }, + "uwsgi": { + "hashes": [ + "sha256:88ab9867d8973d8ae84719cf233b7dafc54326fcaec89683c3f9f77c002cdff9" + ], + "index": "pypi", + "version": "==2.0.20" + }, + "vine": { + "hashes": [ + "sha256:4c9dceab6f76ed92105027c49c823800dd33cacce13bdedc5b914e3514b7fb30", + "sha256:7d3b1624a953da82ef63462013bbd271d3eb75751489f9807598e8f340bd637e" + ], + "markers": "python_full_version >= '3.6.0'", + "version": "==5.0.0" + }, + "watchgod": { + "hashes": [ + "sha256:29a1d8f25e1721ddb73981652ca318c47387ffb12ec4171ddd7b9d01540033b1", + "sha256:339c2cfede1ccc1e277bbf5e82e42886f3c80801b01f45ab10d9461c4118b5eb" + ], + "version": "==0.8" + }, + "wcwidth": { + "hashes": [ + "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784", + "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83" + ], + "version": "==0.2.5" + }, + "websockets": { + "hashes": [ + "sha256:038afef2a05893578d10dadbdbb5f112bd115c46347e1efe99f6a356ff062138", + "sha256:05f6e9757017270e7a92a2975e2ae88a9a582ffc4629086fd6039aa80e99cd86", + "sha256:0b66421f9f13d4df60cd48ab977ed2c2b6c9147ae1a33caf5a9f46294422fda1", + "sha256:0cd02f36d37e503aca88ab23cc0a1a0e92a263d37acf6331521eb38040dcf77b", + "sha256:0f73cb2526d6da268e86977b2c4b58f2195994e53070fe567d5487c6436047e6", + "sha256:117383d0a17a0dda349f7a8790763dde75c1508ff8e4d6e8328b898b7df48397", + "sha256:1c1f3b18c8162e3b09761d0c6a0305fd642934202541cc511ef972cb9463261e", + "sha256:1c9031e90ebfc486e9cdad532b94004ade3aa39a31d3c46c105bb0b579cd2490", + "sha256:2349fa81b6b959484bb2bda556ccb9eb70ba68987646a0f8a537a1a18319fb03", + "sha256:24b879ba7db12bb525d4e58089fcbe6a3df3ce4666523183654170e86d372cbe", + "sha256:2aa9b91347ecd0412683f28aabe27f6bad502d89bd363b76e0a3508b1596402e", + "sha256:56d48eebe9e39ce0d68701bce3b21df923aa05dcc00f9fd8300de1df31a7c07c", + "sha256:5a38a0175ae82e4a8c4bac29fc01b9ee26d7d5a614e5ee11e7813c68a7d938ce", + "sha256:5b04270b5613f245ec84bb2c6a482a9d009aefad37c0575f6cda8499125d5d5c", + "sha256:6193bbc1ee63aadeb9a4d81de0e19477401d150d506aee772d8380943f118186", + "sha256:669e54228a4d9457abafed27cbf0e2b9f401445c4dfefc12bf8e4db9751703b8", + "sha256:6a009eb551c46fd79737791c0c833fc0e5b56bcd1c3057498b262d660b92e9cd", + "sha256:71a4491cfe7a9f18ee57d41163cb6a8a3fa591e0f0564ca8b0ed86b2a30cced4", + "sha256:7b38a5c9112e3dbbe45540f7b60c5204f49b3cb501b40950d6ab34cd202ab1d0", + "sha256:7bb9d8a6beca478c7e9bdde0159bd810cc1006ad6a7cb460533bae39da692ca2", + "sha256:82bc33db6d8309dc27a3bee11f7da2288ad925fcbabc2a4bb78f7e9c56249baf", + "sha256:8351c3c86b08156337b0e4ece0e3c5ec3e01fcd14e8950996832a23c99416098", + "sha256:8beac786a388bb99a66c3be4ab0fb38273c0e3bc17f612a4e0a47c4fc8b9c045", + "sha256:97950c7c844ec6f8d292440953ae18b99e3a6a09885e09d20d5e7ecd9b914cf8", + "sha256:98f57b3120f8331cd7440dbe0e776474f5e3632fdaa474af1f6b754955a47d71", + "sha256:9ca2ca05a4c29179f06cf6727b45dba5d228da62623ec9df4184413d8aae6cb9", + "sha256:a03a25d95cc7400bd4d61a63460b5d85a7761c12075ee2f51de1ffe73aa593d3", + "sha256:a10c0c1ee02164246f90053273a42d72a3b2452a7e7486fdae781138cf7fbe2d", + "sha256:a72b92f96e5e540d5dda99ee3346e199ade8df63152fa3c737260da1730c411f", + "sha256:ac081aa0307f263d63c5ff0727935c736c8dad51ddf2dc9f5d0c4759842aefaa", + "sha256:b22bdc795e62e71118b63e14a08bacfa4f262fd2877de7e5b950f5ac16b0348f", + "sha256:b4059e2ccbe6587b6dc9a01db5fc49ead9a884faa4076eea96c5ec62cb32f42a", + "sha256:b7fe45ae43ac814beb8ca09d6995b56800676f2cfa8e23f42839dc69bba34a42", + "sha256:bef03a51f9657fb03d8da6ccd233fe96e04101a852f0ffd35f5b725b28221ff3", + "sha256:bffc65442dd35c473ca9790a3fa3ba06396102a950794f536783f4b8060af8dd", + "sha256:c21a67ab9a94bd53e10bba21912556027fea944648a09e6508415ad14e37c325", + "sha256:c67d9cacb3f6537ca21e9b224d4fd08481538e43bcac08b3d93181b0816def39", + "sha256:c6e56606842bb24e16e36ae7eb308d866b4249cf0be8f63b212f287eeb76b124", + "sha256:cb316b87cbe3c0791c2ad92a5a36bf6adc87c457654335810b25048c1daa6fd5", + "sha256:cef40a1b183dcf39d23b392e9dd1d9b07ab9c46aadf294fff1350fb79146e72b", + "sha256:cf931c33db9c87c53d009856045dd524e4a378445693382a920fa1e0eb77c36c", + "sha256:d4d110a84b63c5cfdd22485acc97b8b919aefeecd6300c0c9d551e055b9a88ea", + "sha256:d5396710f86a306cf52f87fd8ea594a0e894ba0cc5a36059eaca3a477dc332aa", + "sha256:f09f46b1ff6d09b01c7816c50bd1903cf7d02ebbdb63726132717c2fcda835d5", + "sha256:f14bd10e170abc01682a9f8b28b16e6f20acf6175945ef38db6ffe31b0c72c3f", + "sha256:f5c335dc0e7dc271ef36df3f439868b3c790775f345338c2f61a562f1074187b", + "sha256:f8296b8408ec6853b26771599990721a26403e62b9de7e50ac0a056772ac0b5e", + "sha256:fa35c5d1830d0fb7b810324e9eeab9aa92e8f273f11fdbdc0741dcded6d72b9f" + ], + "version": "==10.2" + }, + "wrapt": { + "hashes": [ + "sha256:00108411e0f34c52ce16f81f1d308a571df7784932cc7491d1e94be2ee93374b", + "sha256:01f799def9b96a8ec1ef6b9c1bbaf2bbc859b87545efbecc4a78faea13d0e3a0", + "sha256:09d16ae7a13cff43660155383a2372b4aa09109c7127aa3f24c3cf99b891c330", + "sha256:14e7e2c5f5fca67e9a6d5f753d21f138398cad2b1159913ec9e9a67745f09ba3", + "sha256:167e4793dc987f77fd476862d32fa404d42b71f6a85d3b38cbce711dba5e6b68", + "sha256:1807054aa7b61ad8d8103b3b30c9764de2e9d0c0978e9d3fc337e4e74bf25faa", + "sha256:1f83e9c21cd5275991076b2ba1cd35418af3504667affb4745b48937e214bafe", + "sha256:21b1106bff6ece8cb203ef45b4f5778d7226c941c83aaaa1e1f0f4f32cc148cd", + "sha256:22626dca56fd7f55a0733e604f1027277eb0f4f3d95ff28f15d27ac25a45f71b", + "sha256:23f96134a3aa24cc50614920cc087e22f87439053d886e474638c68c8d15dc80", + "sha256:2498762814dd7dd2a1d0248eda2afbc3dd9c11537bc8200a4b21789b6df6cd38", + "sha256:28c659878f684365d53cf59dc9a1929ea2eecd7ac65da762be8b1ba193f7e84f", + "sha256:2eca15d6b947cfff51ed76b2d60fd172c6ecd418ddab1c5126032d27f74bc350", + "sha256:354d9fc6b1e44750e2a67b4b108841f5f5ea08853453ecbf44c81fdc2e0d50bd", + "sha256:36a76a7527df8583112b24adc01748cd51a2d14e905b337a6fefa8b96fc708fb", + "sha256:3a0a4ca02752ced5f37498827e49c414d694ad7cf451ee850e3ff160f2bee9d3", + "sha256:3a71dbd792cc7a3d772ef8cd08d3048593f13d6f40a11f3427c000cf0a5b36a0", + "sha256:3a88254881e8a8c4784ecc9cb2249ff757fd94b911d5df9a5984961b96113fff", + "sha256:47045ed35481e857918ae78b54891fac0c1d197f22c95778e66302668309336c", + "sha256:4775a574e9d84e0212f5b18886cace049a42e13e12009bb0491562a48bb2b758", + "sha256:493da1f8b1bb8a623c16552fb4a1e164c0200447eb83d3f68b44315ead3f9036", + "sha256:4b847029e2d5e11fd536c9ac3136ddc3f54bc9488a75ef7d040a3900406a91eb", + "sha256:59d7d92cee84a547d91267f0fea381c363121d70fe90b12cd88241bd9b0e1763", + "sha256:5a0898a640559dec00f3614ffb11d97a2666ee9a2a6bad1259c9facd01a1d4d9", + "sha256:5a9a1889cc01ed2ed5f34574c90745fab1dd06ec2eee663e8ebeefe363e8efd7", + "sha256:5b835b86bd5a1bdbe257d610eecab07bf685b1af2a7563093e0e69180c1d4af1", + "sha256:5f24ca7953f2643d59a9c87d6e272d8adddd4a53bb62b9208f36db408d7aafc7", + "sha256:61e1a064906ccba038aa3c4a5a82f6199749efbbb3cef0804ae5c37f550eded0", + "sha256:65bf3eb34721bf18b5a021a1ad7aa05947a1767d1aa272b725728014475ea7d5", + "sha256:6807bcee549a8cb2f38f73f469703a1d8d5d990815c3004f21ddb68a567385ce", + "sha256:68aeefac31c1f73949662ba8affaf9950b9938b712fb9d428fa2a07e40ee57f8", + "sha256:6915682f9a9bc4cf2908e83caf5895a685da1fbd20b6d485dafb8e218a338279", + "sha256:6d9810d4f697d58fd66039ab959e6d37e63ab377008ef1d63904df25956c7db0", + "sha256:729d5e96566f44fccac6c4447ec2332636b4fe273f03da128fff8d5559782b06", + "sha256:748df39ed634851350efa87690c2237a678ed794fe9ede3f0d79f071ee042561", + "sha256:763a73ab377390e2af26042f685a26787c402390f682443727b847e9496e4a2a", + "sha256:8323a43bd9c91f62bb7d4be74cc9ff10090e7ef820e27bfe8815c57e68261311", + "sha256:8529b07b49b2d89d6917cfa157d3ea1dfb4d319d51e23030664a827fe5fd2131", + "sha256:87fa943e8bbe40c8c1ba4086971a6fefbf75e9991217c55ed1bcb2f1985bd3d4", + "sha256:88236b90dda77f0394f878324cfbae05ae6fde8a84d548cfe73a75278d760291", + "sha256:891c353e95bb11abb548ca95c8b98050f3620a7378332eb90d6acdef35b401d4", + "sha256:89ba3d548ee1e6291a20f3c7380c92f71e358ce8b9e48161401e087e0bc740f8", + "sha256:8c6be72eac3c14baa473620e04f74186c5d8f45d80f8f2b4eda6e1d18af808e8", + "sha256:9a242871b3d8eecc56d350e5e03ea1854de47b17f040446da0e47dc3e0b9ad4d", + "sha256:9a3ff5fb015f6feb78340143584d9f8a0b91b6293d6b5cf4295b3e95d179b88c", + "sha256:9a5a544861b21e0e7575b6023adebe7a8c6321127bb1d238eb40d99803a0e8bd", + "sha256:9d57677238a0c5411c76097b8b93bdebb02eb845814c90f0b01727527a179e4d", + "sha256:9d8c68c4145041b4eeae96239802cfdfd9ef927754a5be3f50505f09f309d8c6", + "sha256:9d9fcd06c952efa4b6b95f3d788a819b7f33d11bea377be6b8980c95e7d10775", + "sha256:a0057b5435a65b933cbf5d859cd4956624df37b8bf0917c71756e4b3d9958b9e", + "sha256:a65bffd24409454b889af33b6c49d0d9bcd1a219b972fba975ac935f17bdf627", + "sha256:b0ed6ad6c9640671689c2dbe6244680fe8b897c08fd1fab2228429b66c518e5e", + "sha256:b21650fa6907e523869e0396c5bd591cc326e5c1dd594dcdccac089561cacfb8", + "sha256:b3f7e671fb19734c872566e57ce7fc235fa953d7c181bb4ef138e17d607dc8a1", + "sha256:b77159d9862374da213f741af0c361720200ab7ad21b9f12556e0eb95912cd48", + "sha256:bb36fbb48b22985d13a6b496ea5fb9bb2a076fea943831643836c9f6febbcfdc", + "sha256:d066ffc5ed0be00cd0352c95800a519cf9e4b5dd34a028d301bdc7177c72daf3", + "sha256:d332eecf307fca852d02b63f35a7872de32d5ba8b4ec32da82f45df986b39ff6", + "sha256:d808a5a5411982a09fef6b49aac62986274ab050e9d3e9817ad65b2791ed1425", + "sha256:d9bdfa74d369256e4218000a629978590fd7cb6cf6893251dad13d051090436d", + "sha256:db6a0ddc1282ceb9032e41853e659c9b638789be38e5b8ad7498caac00231c23", + "sha256:debaf04f813ada978d7d16c7dfa16f3c9c2ec9adf4656efdc4defdf841fc2f0c", + "sha256:f0408e2dbad9e82b4c960274214af533f856a199c9274bd4aff55d4634dedc33", + "sha256:f2f3bc7cd9c9fcd39143f11342eb5963317bd54ecc98e3650ca22704b69d9653" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==1.14.0" + }, + "zipp": { + "hashes": [ + "sha256:9f50f446828eb9d45b267433fd3e9da8d801f614129124863f9c51ebceafb87d", + "sha256:b47250dd24f92b7dd6a0a8fc5244da14608f3ca90a5efcd37a3b1642fac9a375" + ], + "markers": "python_version >= '3.7'", + "version": "==3.7.0" + }, + "zope.interface": { + "hashes": [ + "sha256:08f9636e99a9d5410181ba0729e0408d3d8748026ea938f3b970a0249daa8192", + "sha256:0b465ae0962d49c68aa9733ba92a001b2a0933c317780435f00be7ecb959c702", + "sha256:0cba8477e300d64a11a9789ed40ee8932b59f9ee05f85276dbb4b59acee5dd09", + "sha256:0cee5187b60ed26d56eb2960136288ce91bcf61e2a9405660d271d1f122a69a4", + "sha256:0ea1d73b7c9dcbc5080bb8aaffb776f1c68e807767069b9ccdd06f27a161914a", + "sha256:0f91b5b948686659a8e28b728ff5e74b1be6bf40cb04704453617e5f1e945ef3", + "sha256:15e7d1f7a6ee16572e21e3576d2012b2778cbacf75eb4b7400be37455f5ca8bf", + "sha256:17776ecd3a1fdd2b2cd5373e5ef8b307162f581c693575ec62e7c5399d80794c", + "sha256:194d0bcb1374ac3e1e023961610dc8f2c78a0f5f634d0c737691e215569e640d", + "sha256:1c0e316c9add0db48a5b703833881351444398b04111188069a26a61cfb4df78", + "sha256:205e40ccde0f37496904572035deea747390a8b7dc65146d30b96e2dd1359a83", + "sha256:273f158fabc5ea33cbc936da0ab3d4ba80ede5351babc4f577d768e057651531", + "sha256:2876246527c91e101184f63ccd1d716ec9c46519cc5f3d5375a3351c46467c46", + "sha256:2c98384b254b37ce50eddd55db8d381a5c53b4c10ee66e1e7fe749824f894021", + "sha256:2e5a26f16503be6c826abca904e45f1a44ff275fdb7e9d1b75c10671c26f8b94", + "sha256:334701327f37c47fa628fc8b8d28c7d7730ce7daaf4bda1efb741679c2b087fc", + "sha256:3748fac0d0f6a304e674955ab1365d515993b3a0a865e16a11ec9d86fb307f63", + "sha256:3c02411a3b62668200910090a0dff17c0b25aaa36145082a5a6adf08fa281e54", + "sha256:3dd4952748521205697bc2802e4afac5ed4b02909bb799ba1fe239f77fd4e117", + "sha256:3f24df7124c323fceb53ff6168da70dbfbae1442b4f3da439cd441681f54fe25", + "sha256:469e2407e0fe9880ac690a3666f03eb4c3c444411a5a5fddfdabc5d184a79f05", + "sha256:4de4bc9b6d35c5af65b454d3e9bc98c50eb3960d5a3762c9438df57427134b8e", + "sha256:5208ebd5152e040640518a77827bdfcc73773a15a33d6644015b763b9c9febc1", + "sha256:52de7fc6c21b419078008f697fd4103dbc763288b1406b4562554bd47514c004", + "sha256:5bb3489b4558e49ad2c5118137cfeaf59434f9737fa9c5deefc72d22c23822e2", + "sha256:5dba5f530fec3f0988d83b78cc591b58c0b6eb8431a85edd1569a0539a8a5a0e", + "sha256:5dd9ca406499444f4c8299f803d4a14edf7890ecc595c8b1c7115c2342cadc5f", + "sha256:5f931a1c21dfa7a9c573ec1f50a31135ccce84e32507c54e1ea404894c5eb96f", + "sha256:63b82bb63de7c821428d513607e84c6d97d58afd1fe2eb645030bdc185440120", + "sha256:66c0061c91b3b9cf542131148ef7ecbecb2690d48d1612ec386de9d36766058f", + "sha256:6f0c02cbb9691b7c91d5009108f975f8ffeab5dff8f26d62e21c493060eff2a1", + "sha256:71aace0c42d53abe6fc7f726c5d3b60d90f3c5c055a447950ad6ea9cec2e37d9", + "sha256:7d97a4306898b05404a0dcdc32d9709b7d8832c0c542b861d9a826301719794e", + "sha256:7df1e1c05304f26faa49fa752a8c690126cf98b40b91d54e6e9cc3b7d6ffe8b7", + "sha256:8270252effc60b9642b423189a2fe90eb6b59e87cbee54549db3f5562ff8d1b8", + "sha256:867a5ad16892bf20e6c4ea2aab1971f45645ff3102ad29bd84c86027fa99997b", + "sha256:877473e675fdcc113c138813a5dd440da0769a2d81f4d86614e5d62b69497155", + "sha256:8892f89999ffd992208754851e5a052f6b5db70a1e3f7d54b17c5211e37a98c7", + "sha256:9a9845c4c6bb56e508651f005c4aeb0404e518c6f000d5a1123ab077ab769f5c", + "sha256:a1e6e96217a0f72e2b8629e271e1b280c6fa3fe6e59fa8f6701bec14e3354325", + "sha256:a8156e6a7f5e2a0ff0c5b21d6bcb45145efece1909efcbbbf48c56f8da68221d", + "sha256:a9506a7e80bcf6eacfff7f804c0ad5350c8c95b9010e4356a4b36f5322f09abb", + "sha256:af310ec8335016b5e52cae60cda4a4f2a60a788cbb949a4fbea13d441aa5a09e", + "sha256:b0297b1e05fd128d26cc2460c810d42e205d16d76799526dfa8c8ccd50e74959", + "sha256:bf68f4b2b6683e52bec69273562df15af352e5ed25d1b6641e7efddc5951d1a7", + "sha256:d0c1bc2fa9a7285719e5678584f6b92572a5b639d0e471bb8d4b650a1a910920", + "sha256:d4d9d6c1a455d4babd320203b918ccc7fcbefe308615c521062bc2ba1aa4d26e", + "sha256:db1fa631737dab9fa0b37f3979d8d2631e348c3b4e8325d6873c2541d0ae5a48", + "sha256:dd93ea5c0c7f3e25335ab7d22a507b1dc43976e1345508f845efc573d3d779d8", + "sha256:f44e517131a98f7a76696a7b21b164bcb85291cee106a23beccce454e1f433a4", + "sha256:f7ee479e96f7ee350db1cf24afa5685a5899e2b34992fb99e1f7c1b0b758d263" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==5.4.0" + } + }, + "develop": { + "appdirs": { + "hashes": [ + "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", + "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" + ], + "version": "==1.4.4" + }, + "asgiref": { + "hashes": [ + "sha256:2f8abc20f7248433085eda803936d98992f1343ddb022065779f37c5da0181d0", + "sha256:88d59c13d634dcffe0510be048210188edd79aeccb6a6c9028cdad6f31d730a9" + ], + "markers": "python_version >= '3.7'", + "version": "==3.5.0" + }, + "attrs": { + "hashes": [ + "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4", + "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==21.4.0" + }, + "black": { + "hashes": [ + "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b", + "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539" + ], + "index": "pypi", + "version": "==19.10b0" + }, + "certifi": { + "hashes": [ + "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872", + "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569" + ], + "version": "==2021.10.8" + }, + "charset-normalizer": { + "hashes": [ + "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597", + "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df" + ], + "markers": "python_version >= '3'", + "version": "==2.0.12" + }, + "click": { + "hashes": [ + "sha256:6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1", + "sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb" + ], + "markers": "python_full_version >= '3.6.0'", + "version": "==8.0.4" + }, + "codecov": { + "hashes": [ + "sha256:585dc217dc3d8185198ceb402f85d5cb5dbfa0c5f350a5abcdf9e347776a5b47", + "sha256:782a8e5352f22593cbc5427a35320b99490eb24d9dcfa2155fd99d2b75cfb635", + "sha256:a0da46bb5025426da895af90938def8ee12d37fcbcbbbc15b6dc64cf7ebc51c1" + ], + "index": "pypi", + "version": "==2.1.12" + }, + "coverage": { + "hashes": [ + "sha256:03e2a7826086b91ef345ff18742ee9fc47a6839ccd517061ef8fa1976e652ce9", + "sha256:07e6db90cd9686c767dcc593dff16c8c09f9814f5e9c51034066cad3373b914d", + "sha256:18d520c6860515a771708937d2f78f63cc47ab3b80cb78e86573b0a760161faf", + "sha256:1ebf730d2381158ecf3dfd4453fbca0613e16eaa547b4170e2450c9707665ce7", + "sha256:21b7745788866028adeb1e0eca3bf1101109e2dc58456cb49d2d9b99a8c516e6", + "sha256:26e2deacd414fc2f97dd9f7676ee3eaecd299ca751412d89f40bc01557a6b1b4", + "sha256:2c6dbb42f3ad25760010c45191e9757e7dce981cbfb90e42feef301d71540059", + "sha256:2fea046bfb455510e05be95e879f0e768d45c10c11509e20e06d8fcaa31d9e39", + "sha256:34626a7eee2a3da12af0507780bb51eb52dca0e1751fd1471d0810539cefb536", + "sha256:37d1141ad6b2466a7b53a22e08fe76994c2d35a5b6b469590424a9953155afac", + "sha256:46191097ebc381fbf89bdce207a6c107ac4ec0890d8d20f3360345ff5976155c", + "sha256:4dd8bafa458b5c7d061540f1ee9f18025a68e2d8471b3e858a9dad47c8d41903", + "sha256:4e21876082ed887baed0146fe222f861b5815455ada3b33b890f4105d806128d", + "sha256:58303469e9a272b4abdb9e302a780072c0633cdcc0165db7eec0f9e32f901e05", + "sha256:5ca5aeb4344b30d0bec47481536b8ba1181d50dbe783b0e4ad03c95dc1296684", + "sha256:68353fe7cdf91f109fc7d474461b46e7f1f14e533e911a2a2cbb8b0fc8613cf1", + "sha256:6f89d05e028d274ce4fa1a86887b071ae1755082ef94a6740238cd7a8178804f", + "sha256:7a15dc0a14008f1da3d1ebd44bdda3e357dbabdf5a0b5034d38fcde0b5c234b7", + "sha256:8bdde1177f2311ee552f47ae6e5aa7750c0e3291ca6b75f71f7ffe1f1dab3dca", + "sha256:8ce257cac556cb03be4a248d92ed36904a59a4a5ff55a994e92214cde15c5bad", + "sha256:8cf5cfcb1521dc3255d845d9dca3ff204b3229401994ef8d1984b32746bb45ca", + "sha256:8fbbdc8d55990eac1b0919ca69eb5a988a802b854488c34b8f37f3e2025fa90d", + "sha256:9548f10d8be799551eb3a9c74bbf2b4934ddb330e08a73320123c07f95cc2d92", + "sha256:96f8a1cb43ca1422f36492bebe63312d396491a9165ed3b9231e778d43a7fca4", + "sha256:9b27d894748475fa858f9597c0ee1d4829f44683f3813633aaf94b19cb5453cf", + "sha256:9baff2a45ae1f17c8078452e9e5962e518eab705e50a0aa8083733ea7d45f3a6", + "sha256:a2a8b8bcc399edb4347a5ca8b9b87e7524c0967b335fbb08a83c8421489ddee1", + "sha256:acf53bc2cf7282ab9b8ba346746afe703474004d9e566ad164c91a7a59f188a4", + "sha256:b0be84e5a6209858a1d3e8d1806c46214e867ce1b0fd32e4ea03f4bd8b2e3359", + "sha256:b31651d018b23ec463e95cf10070d0b2c548aa950a03d0b559eaa11c7e5a6fa3", + "sha256:b78e5afb39941572209f71866aa0b206c12f0109835aa0d601e41552f9b3e620", + "sha256:c76aeef1b95aff3905fb2ae2d96e319caca5b76fa41d3470b19d4e4a3a313512", + "sha256:dd035edafefee4d573140a76fdc785dc38829fe5a455c4bb12bac8c20cfc3d69", + "sha256:dd6fe30bd519694b356cbfcaca9bd5c1737cddd20778c6a581ae20dc8c04def2", + "sha256:e5f4e1edcf57ce94e5475fe09e5afa3e3145081318e5fd1a43a6b4539a97e518", + "sha256:ec6bc7fe73a938933d4178c9b23c4e0568e43e220aef9472c4f6044bfc6dd0f0", + "sha256:f1555ea6d6da108e1999b2463ea1003fe03f29213e459145e70edbaf3e004aaa", + "sha256:f5fa5803f47e095d7ad8443d28b01d48c0359484fec1b9d8606d0e3282084bc4", + "sha256:f7331dbf301b7289013175087636bbaf5b2405e57259dd2c42fdcc9fcc47325e", + "sha256:f9987b0354b06d4df0f4d3e0ec1ae76d7ce7cbca9a2f98c25041eb79eec766f1", + "sha256:fd9e830e9d8d89b20ab1e5af09b32d33e1a08ef4c4e14411e559556fd788e6b2" + ], + "markers": "python_version >= '3.7'", + "version": "==6.3.2" + }, + "django": { + "hashes": [ + "sha256:32ce792ee9b6a0cbbec340123e229ac9f765dff8c2a4ae9247a14b2ba3a365a7", + "sha256:baf099db36ad31f970775d0be5587cc58a6256a6771a44eb795b554d45f211b8" + ], + "index": "pypi", + "version": "==3.1.7" + }, + "django-debug-toolbar": { + "hashes": [ + "sha256:644bbd5c428d3283aa9115722471769cac1bec189edf3a0c855fd8ff870375a9", + "sha256:6b633b6cfee24f232d73569870f19aa86c819d750e7f3e833f2344a9eb4b4409" + ], + "index": "pypi", + "version": "==3.2.4" + }, + "django-extensions": { + "hashes": [ + "sha256:28e1e1bf49f0e00307ba574d645b0af3564c981a6dfc87209d48cb98f77d0b1a", + "sha256:9238b9e016bb0009d621e05cf56ea8ce5cce9b32e91ad2026996a7377ca28069" + ], + "index": "pypi", + "version": "==3.1.5" + }, + "flake8": { + "hashes": [ + "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d", + "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d" + ], + "index": "pypi", + "version": "==4.0.1" + }, + "flake8-absolute-import": { + "hashes": [ + "sha256:d24f189bca52ffc0d13e8046606ea42d22a9ad9d409bf39e52b93493cf2ffd2c" + ], + "index": "pypi", + "version": "==1.0.0.1" + }, + "flake8-isort": { + "hashes": [ + "sha256:c4e8b6dcb7be9b71a02e6e5d4196cefcef0f3447be51e82730fb336fff164949", + "sha256:d814304ab70e6e58859bc5c3e221e2e6e71c958e7005239202fee19c24f82717" + ], + "index": "pypi", + "version": "==4.1.1" + }, + "flake8-quotes": { + "hashes": [ + "sha256:633adca6fb8a08131536af0d750b44d6985b9aba46f498871e21588c3e6f525a" + ], + "index": "pypi", + "version": "==3.3.1" + }, + "idna": { + "hashes": [ + "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", + "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" + ], + "version": "==3.3" + }, + "importlib-metadata": { + "hashes": [ + "sha256:1208431ca90a8cca1a6b8af391bb53c1a2db74e5d1cef6ddced95d4b2062edc6", + "sha256:ea4c597ebf37142f827b8f39299579e31685c31d3a438b59f469406afd0f2539" + ], + "markers": "python_version < '3.8'", + "version": "==4.11.3" + }, + "isort": { + "hashes": [ + "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7", + "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951" + ], + "markers": "python_version < '4.0' and python_full_version >= '3.6.1'", + "version": "==5.10.1" + }, + "lxml": { + "hashes": [ + "sha256:078306d19a33920004addeb5f4630781aaeabb6a8d01398045fcde085091a169", + "sha256:0c1978ff1fd81ed9dcbba4f91cf09faf1f8082c9d72eb122e92294716c605428", + "sha256:1010042bfcac2b2dc6098260a2ed022968dbdfaf285fc65a3acf8e4eb1ffd1bc", + "sha256:1d650812b52d98679ed6c6b3b55cbb8fe5a5460a0aef29aeb08dc0b44577df85", + "sha256:20b8a746a026017acf07da39fdb10aa80ad9877046c9182442bf80c84a1c4696", + "sha256:2403a6d6fb61c285969b71f4a3527873fe93fd0abe0832d858a17fe68c8fa507", + "sha256:24f5c5ae618395ed871b3d8ebfcbb36e3f1091fd847bf54c4de623f9107942f3", + "sha256:28d1af847786f68bec57961f31221125c29d6f52d9187c01cd34dc14e2b29430", + "sha256:31499847fc5f73ee17dbe1b8e24c6dafc4e8d5b48803d17d22988976b0171f03", + "sha256:31ba2cbc64516dcdd6c24418daa7abff989ddf3ba6d3ea6f6ce6f2ed6e754ec9", + "sha256:330bff92c26d4aee79c5bc4d9967858bdbe73fdbdbacb5daf623a03a914fe05b", + "sha256:5045ee1ccd45a89c4daec1160217d363fcd23811e26734688007c26f28c9e9e7", + "sha256:52cbf2ff155b19dc4d4100f7442f6a697938bf4493f8d3b0c51d45568d5666b5", + "sha256:530f278849031b0eb12f46cca0e5db01cfe5177ab13bd6878c6e739319bae654", + "sha256:545bd39c9481f2e3f2727c78c169425efbfb3fbba6e7db4f46a80ebb249819ca", + "sha256:5804e04feb4e61babf3911c2a974a5b86f66ee227cc5006230b00ac6d285b3a9", + "sha256:5a58d0b12f5053e270510bf12f753a76aaf3d74c453c00942ed7d2c804ca845c", + "sha256:5f148b0c6133fb928503cfcdfdba395010f997aa44bcf6474fcdd0c5398d9b63", + "sha256:5f7d7d9afc7b293147e2d506a4596641d60181a35279ef3aa5778d0d9d9123fe", + "sha256:60d2f60bd5a2a979df28ab309352cdcf8181bda0cca4529769a945f09aba06f9", + "sha256:6259b511b0f2527e6d55ad87acc1c07b3cbffc3d5e050d7e7bcfa151b8202df9", + "sha256:6268e27873a3d191849204d00d03f65c0e343b3bcb518a6eaae05677c95621d1", + "sha256:627e79894770783c129cc5e89b947e52aa26e8e0557c7e205368a809da4b7939", + "sha256:62f93eac69ec0f4be98d1b96f4d6b964855b8255c345c17ff12c20b93f247b68", + "sha256:6d6483b1229470e1d8835e52e0ff3c6973b9b97b24cd1c116dca90b57a2cc613", + "sha256:6f7b82934c08e28a2d537d870293236b1000d94d0b4583825ab9649aef7ddf63", + "sha256:6fe4ef4402df0250b75ba876c3795510d782def5c1e63890bde02d622570d39e", + "sha256:719544565c2937c21a6f76d520e6e52b726d132815adb3447ccffbe9f44203c4", + "sha256:730766072fd5dcb219dd2b95c4c49752a54f00157f322bc6d71f7d2a31fecd79", + "sha256:74eb65ec61e3c7c019d7169387d1b6ffcfea1b9ec5894d116a9a903636e4a0b1", + "sha256:7993232bd4044392c47779a3c7e8889fea6883be46281d45a81451acfd704d7e", + "sha256:80bbaddf2baab7e6de4bc47405e34948e694a9efe0861c61cdc23aa774fcb141", + "sha256:86545e351e879d0b72b620db6a3b96346921fa87b3d366d6c074e5a9a0b8dadb", + "sha256:891dc8f522d7059ff0024cd3ae79fd224752676447f9c678f2a5c14b84d9a939", + "sha256:8a31f24e2a0b6317f33aafbb2f0895c0bce772980ae60c2c640d82caac49628a", + "sha256:8b99ec73073b37f9ebe8caf399001848fced9c08064effdbfc4da2b5a8d07b93", + "sha256:986b7a96228c9b4942ec420eff37556c5777bfba6758edcb95421e4a614b57f9", + "sha256:a1547ff4b8a833511eeaceacbcd17b043214fcdb385148f9c1bc5556ca9623e2", + "sha256:a2bfc7e2a0601b475477c954bf167dee6d0f55cb167e3f3e7cefad906e7759f6", + "sha256:a3c5f1a719aa11866ffc530d54ad965063a8cbbecae6515acbd5f0fae8f48eaa", + "sha256:a9f1c3489736ff8e1c7652e9dc39f80cff820f23624f23d9eab6e122ac99b150", + "sha256:aa0cf4922da7a3c905d000b35065df6184c0dc1d866dd3b86fd961905bbad2ea", + "sha256:ad4332a532e2d5acb231a2e5d33f943750091ee435daffca3fec0a53224e7e33", + "sha256:b2582b238e1658c4061ebe1b4df53c435190d22457642377fd0cb30685cdfb76", + "sha256:b6fc2e2fb6f532cf48b5fed57567ef286addcef38c28874458a41b7837a57807", + "sha256:b92d40121dcbd74831b690a75533da703750f7041b4bf951befc657c37e5695a", + "sha256:bbab6faf6568484707acc052f4dfc3802bdb0cafe079383fbaa23f1cdae9ecd4", + "sha256:c0b88ed1ae66777a798dc54f627e32d3b81c8009967c63993c450ee4cbcbec15", + "sha256:ce13d6291a5f47c1c8dbd375baa78551053bc6b5e5c0e9bb8e39c0a8359fd52f", + "sha256:db3535733f59e5605a88a706824dfcb9bd06725e709ecb017e165fc1d6e7d429", + "sha256:dd10383f1d6b7edf247d0960a3db274c07e96cf3a3fc7c41c8448f93eac3fb1c", + "sha256:e01f9531ba5420838c801c21c1b0f45dbc9607cb22ea2cf132844453bec863a5", + "sha256:e11527dc23d5ef44d76fef11213215c34f36af1608074561fcc561d983aeb870", + "sha256:e1ab2fac607842ac36864e358c42feb0960ae62c34aa4caaf12ada0a1fb5d99b", + "sha256:e1fd7d2fe11f1cb63d3336d147c852f6d07de0d0020d704c6031b46a30b02ca8", + "sha256:e9f84ed9f4d50b74fbc77298ee5c870f67cb7e91dcdc1a6915cb1ff6a317476c", + "sha256:ec4b4e75fc68da9dc0ed73dcdb431c25c57775383fec325d23a770a64e7ebc87", + "sha256:f10ce66fcdeb3543df51d423ede7e238be98412232fca5daec3e54bcd16b8da0", + "sha256:f63f62fc60e6228a4ca9abae28228f35e1bd3ce675013d1dfb828688d50c6e23", + "sha256:fa56bb08b3dd8eac3a8c5b7d075c94e74f755fd9d8a04543ae8d37b1612dd170", + "sha256:fa9b7c450be85bfc6cd39f6df8c5b8cbd76b5d6fc1f69efec80203f9894b885f" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==4.8.0" + }, + "mccabe": { + "hashes": [ + "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", + "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" + ], + "version": "==0.6.1" + }, + "parameterized": { + "hashes": [ + "sha256:41bbff37d6186430f77f900d777e5bb6a24928a1c46fb1de692f8b52b8833b5c", + "sha256:9cbb0b69a03e8695d68b3399a8a5825200976536fe1cb79db60ed6a4c8c9efe9" + ], + "index": "pypi", + "version": "==0.8.1" + }, + "pathspec": { + "hashes": [ + "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a", + "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1" + ], + "version": "==0.9.0" + }, + "pycodestyle": { + "hashes": [ + "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20", + "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==2.8.0" + }, + "pyflakes": { + "hashes": [ + "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c", + "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.4.0" + }, + "pytz": { + "hashes": [ + "sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7", + "sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c" + ], + "version": "==2022.1" + }, + "regex": { + "hashes": [ + "sha256:0066a6631c92774391f2ea0f90268f0d82fffe39cb946f0f9c6b382a1c61a5e5", + "sha256:0100f0ded953b6b17f18207907159ba9be3159649ad2d9b15535a74de70359d3", + "sha256:01c913cf573d1da0b34c9001a94977273b5ee2fe4cb222a5d5b320f3a9d1a835", + "sha256:0214ff6dff1b5a4b4740cfe6e47f2c4c92ba2938fca7abbea1359036305c132f", + "sha256:029e9e7e0d4d7c3446aa92474cbb07dafb0b2ef1d5ca8365f059998c010600e6", + "sha256:0317eb6331146c524751354ebef76a7a531853d7207a4d760dfb5f553137a2a4", + "sha256:04b5ee2b6d29b4a99d38a6469aa1db65bb79d283186e8460542c517da195a8f6", + "sha256:04c09b9651fa814eeeb38e029dc1ae83149203e4eeb94e52bb868fadf64852bc", + "sha256:058054c7a54428d5c3e3739ac1e363dc9347d15e64833817797dc4f01fb94bb8", + "sha256:060f9066d2177905203516c62c8ea0066c16c7342971d54204d4e51b13dfbe2e", + "sha256:0a7b75cc7bb4cc0334380053e4671c560e31272c9d2d5a6c4b8e9ae2c9bd0f82", + "sha256:0e2630ae470d6a9f8e4967388c1eda4762706f5750ecf387785e0df63a4cc5af", + "sha256:174d964bc683b1e8b0970e1325f75e6242786a92a22cedb2a6ec3e4ae25358bd", + "sha256:25ecb1dffc5e409ca42f01a2b2437f93024ff1612c1e7983bad9ee191a5e8828", + "sha256:286908cbe86b1a0240a867aecfe26a439b16a1f585d2de133540549831f8e774", + "sha256:303b15a3d32bf5fe5a73288c316bac5807587f193ceee4eb6d96ee38663789fa", + "sha256:34bb30c095342797608727baf5c8aa122406aa5edfa12107b8e08eb432d4c5d7", + "sha256:3e265b388cc80c7c9c01bb4f26c9e536c40b2c05b7231fbb347381a2e1c8bf43", + "sha256:3e4d710ff6539026e49f15a3797c6b1053573c2b65210373ef0eec24480b900b", + "sha256:42eb13b93765c6698a5ab3bcd318d8c39bb42e5fa8a7fcf7d8d98923f3babdb1", + "sha256:48081b6bff550fe10bcc20c01cf6c83dbca2ccf74eeacbfac240264775fd7ecf", + "sha256:491fc754428514750ab21c2d294486223ce7385446f2c2f5df87ddbed32979ae", + "sha256:4d1445824944e642ffa54c4f512da17a953699c563a356d8b8cbdad26d3b7598", + "sha256:530a3a16e57bd3ea0dff5ec2695c09632c9d6c549f5869d6cf639f5f7153fb9c", + "sha256:591d4fba554f24bfa0421ba040cd199210a24301f923ed4b628e1e15a1001ff4", + "sha256:5a86cac984da35377ca9ac5e2e0589bd11b3aebb61801204bd99c41fac516f0d", + "sha256:5b1ceede92400b3acfebc1425937454aaf2c62cd5261a3fabd560c61e74f6da3", + "sha256:5b2e24f3ae03af3d8e8e6d824c891fea0ca9035c5d06ac194a2700373861a15c", + "sha256:6504c22c173bb74075d7479852356bb7ca80e28c8e548d4d630a104f231e04fb", + "sha256:673f5a393d603c34477dbad70db30025ccd23996a2d0916e942aac91cc42b31a", + "sha256:6ca6dcd17f537e9f3793cdde20ac6076af51b2bd8ad5fe69fa54373b17b48d3c", + "sha256:6e1d8ed9e61f37881c8db383a124829a6e8114a69bd3377a25aecaeb9b3538f8", + "sha256:75a5e6ce18982f0713c4bac0704bf3f65eed9b277edd3fb9d2b0ff1815943327", + "sha256:76435a92e444e5b8f346aed76801db1c1e5176c4c7e17daba074fbb46cb8d783", + "sha256:764e66a0e382829f6ad3bbce0987153080a511c19eb3d2f8ead3f766d14433ac", + "sha256:78ce90c50d0ec970bd0002462430e00d1ecfd1255218d52d08b3a143fe4bde18", + "sha256:794a6bc66c43db8ed06698fc32aaeaac5c4812d9f825e9589e56f311da7becd9", + "sha256:797437e6024dc1589163675ae82f303103063a0a580c6fd8d0b9a0a6708da29e", + "sha256:7b7494df3fdcc95a1f76cf134d00b54962dd83189520fd35b8fcd474c0aa616d", + "sha256:7d1a6e403ac8f1d91d8f51c441c3f99367488ed822bda2b40836690d5d0059f5", + "sha256:7f63877c87552992894ea1444378b9c3a1d80819880ae226bb30b04789c0828c", + "sha256:8923e1c5231549fee78ff9b2914fad25f2e3517572bb34bfaa3aea682a758683", + "sha256:8afcd1c2297bc989dceaa0379ba15a6df16da69493635e53431d2d0c30356086", + "sha256:8b1cc70e31aacc152a12b39245974c8fccf313187eead559ee5966d50e1b5817", + "sha256:8d1f3ea0d1924feb4cf6afb2699259f658a08ac6f8f3a4a806661c2dfcd66db1", + "sha256:940570c1a305bac10e8b2bc934b85a7709c649317dd16520471e85660275083a", + "sha256:947a8525c0a95ba8dc873191f9017d1b1e3024d4dc757f694e0af3026e34044a", + "sha256:9beb03ff6fe509d6455971c2489dceb31687b38781206bcec8e68bdfcf5f1db2", + "sha256:9c144405220c5ad3f5deab4c77f3e80d52e83804a6b48b6bed3d81a9a0238e4c", + "sha256:a98ae493e4e80b3ded6503ff087a8492db058e9c68de371ac3df78e88360b374", + "sha256:aa2ce79f3889720b46e0aaba338148a1069aea55fda2c29e0626b4db20d9fcb7", + "sha256:aa5eedfc2461c16a092a2fabc5895f159915f25731740c9152a1b00f4bcf629a", + "sha256:ab5d89cfaf71807da93c131bb7a19c3e19eaefd613d14f3bce4e97de830b15df", + "sha256:b4829db3737480a9d5bfb1c0320c4ee13736f555f53a056aacc874f140e98f64", + "sha256:b52771f05cff7517f7067fef19ffe545b1f05959e440d42247a17cd9bddae11b", + "sha256:b8248f19a878c72d8c0a785a2cd45d69432e443c9f10ab924c29adda77b324ae", + "sha256:b9809404528a999cf02a400ee5677c81959bc5cb938fdc696b62eb40214e3632", + "sha256:c155a1a80c5e7a8fa1d9bb1bf3c8a953532b53ab1196092749bafb9d3a7cbb60", + "sha256:c33ce0c665dd325200209340a88438ba7a470bd5f09f7424e520e1a3ff835b52", + "sha256:c5adc854764732dbd95a713f2e6c3e914e17f2ccdc331b9ecb777484c31f73b6", + "sha256:cb374a2a4dba7c4be0b19dc7b1adc50e6c2c26c3369ac629f50f3c198f3743a4", + "sha256:cd00859291658fe1fda48a99559fb34da891c50385b0bfb35b808f98956ef1e7", + "sha256:ce3057777a14a9a1399b81eca6a6bfc9612047811234398b84c54aeff6d536ea", + "sha256:d0a5a1fdc9f148a8827d55b05425801acebeeefc9e86065c7ac8b8cc740a91ff", + "sha256:dad3991f0678facca1a0831ec1ddece2eb4d1dd0f5150acb9440f73a3b863907", + "sha256:dc7b7c16a519d924c50876fb152af661a20749dcbf653c8759e715c1a7a95b18", + "sha256:dcbb7665a9db9f8d7642171152c45da60e16c4f706191d66a1dc47ec9f820aed", + "sha256:df037c01d68d1958dad3463e2881d3638a0d6693483f58ad41001aa53a83fcea", + "sha256:f08a7e4d62ea2a45557f561eea87c907222575ca2134180b6974f8ac81e24f06", + "sha256:f16cf7e4e1bf88fecf7f41da4061f181a6170e179d956420f84e700fb8a3fd6b", + "sha256:f2c53f3af011393ab5ed9ab640fa0876757498aac188f782a0c620e33faa2a3d", + "sha256:f320c070dea3f20c11213e56dbbd7294c05743417cde01392148964b7bc2d31a", + "sha256:f553a1190ae6cd26e553a79f6b6cfba7b8f304da2071052fa33469da075ea625", + "sha256:fc8c7958d14e8270171b3d72792b609c057ec0fa17d507729835b5cff6b7f69a" + ], + "markers": "python_version >= '3.6'", + "version": "==2022.3.15" + }, + "requests": { + "hashes": [ + "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61", + "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", + "version": "==2.27.1" + }, + "sqlparse": { + "hashes": [ + "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae", + "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d" + ], + "markers": "python_version >= '3.5'", + "version": "==0.4.2" + }, + "tblib": { + "hashes": [ + "sha256:059bd77306ea7b419d4f76016aef6d7027cc8a0785579b5aad198803435f882c", + "sha256:289fa7359e580950e7d9743eab36b0691f0310fce64dee7d9c31065b8f723e23" + ], + "index": "pypi", + "version": "==1.7.0" + }, + "testfixtures": { + "hashes": [ + "sha256:02dae883f567f5b70fd3ad3c9eefb95912e78ac90be6c7444b5e2f46bf572c84", + "sha256:7de200e24f50a4a5d6da7019fb1197aaf5abd475efb2ec2422fdcf2f2eb98c1d" + ], + "version": "==6.18.5" + }, + "toml": { + "hashes": [ + "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", + "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" + ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==0.10.2" + }, + "typed-ast": { + "hashes": [ + "sha256:0eb77764ea470f14fcbb89d51bc6bbf5e7623446ac4ed06cbd9ca9495b62e36e", + "sha256:1098df9a0592dd4c8c0ccfc2e98931278a6c6c53cb3a3e2cf7e9ee3b06153344", + "sha256:183b183b7771a508395d2cbffd6db67d6ad52958a5fdc99f450d954003900266", + "sha256:18fe320f354d6f9ad3147859b6e16649a0781425268c4dde596093177660e71a", + "sha256:26a432dc219c6b6f38be20a958cbe1abffcc5492821d7e27f08606ef99e0dffd", + "sha256:294a6903a4d087db805a7656989f613371915fc45c8cc0ddc5c5a0a8ad9bea4d", + "sha256:31d8c6b2df19a777bc8826770b872a45a1f30cfefcfd729491baa5237faae837", + "sha256:33b4a19ddc9fc551ebabca9765d54d04600c4a50eda13893dadf67ed81d9a098", + "sha256:42c47c3b43fe3a39ddf8de1d40dbbfca60ac8530a36c9b198ea5b9efac75c09e", + "sha256:525a2d4088e70a9f75b08b3f87a51acc9cde640e19cc523c7e41aa355564ae27", + "sha256:58ae097a325e9bb7a684572d20eb3e1809802c5c9ec7108e85da1eb6c1a3331b", + "sha256:676d051b1da67a852c0447621fdd11c4e104827417bf216092ec3e286f7da596", + "sha256:74cac86cc586db8dfda0ce65d8bcd2bf17b58668dfcc3652762f3ef0e6677e76", + "sha256:8c08d6625bb258179b6e512f55ad20f9dfef019bbfbe3095247401e053a3ea30", + "sha256:90904d889ab8e81a956f2c0935a523cc4e077c7847a836abee832f868d5c26a4", + "sha256:963a0ccc9a4188524e6e6d39b12c9ca24cc2d45a71cfdd04a26d883c922b4b78", + "sha256:bbebc31bf11762b63bf61aaae232becb41c5bf6b3461b80a4df7e791fabb3aca", + "sha256:bc2542e83ac8399752bc16e0b35e038bdb659ba237f4222616b4e83fb9654985", + "sha256:c29dd9a3a9d259c9fa19d19738d021632d673f6ed9b35a739f48e5f807f264fb", + "sha256:c7407cfcad702f0b6c0e0f3e7ab876cd1d2c13b14ce770e412c0c4b9728a0f88", + "sha256:da0a98d458010bf4fe535f2d1e367a2e2060e105978873c04c04212fb20543f7", + "sha256:df05aa5b241e2e8045f5f4367a9f6187b09c4cdf8578bb219861c4e27c443db5", + "sha256:f290617f74a610849bd8f5514e34ae3d09eafd521dceaa6cf68b3f4414266d4e", + "sha256:f30ddd110634c2d7534b2d4e0e22967e88366b0d356b24de87419cc4410c41b7" + ], + "markers": "python_version >= '3.6'", + "version": "==1.5.2" + }, + "typing-extensions": { + "hashes": [ + "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42", + "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2" + ], + "index": "pypi", + "version": "==4.1.1" + }, + "unittest-xml-reporting": { + "hashes": [ + "sha256:edd8d3170b40c3a81b8cf910f46c6a304ae2847ec01036d02e9c0f9b85762d28", + "sha256:f3d7402e5b3ac72a5ee3149278339db1a8f932ee405f48bcb9c681372f2717d5" + ], + "index": "pypi", + "version": "==3.2.0" + }, + "urllib3": { + "hashes": [ + "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14", + "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", + "version": "==1.26.9" + }, + "zipp": { + "hashes": [ + "sha256:9f50f446828eb9d45b267433fd3e9da8d801f614129124863f9c51ebceafb87d", + "sha256:b47250dd24f92b7dd6a0a8fc5244da14608f3ca90a5efcd37a3b1642fac9a375" + ], + "markers": "python_version >= '3.7'", + "version": "==3.7.0" + } + } +} \ No newline at end of file diff --git a/backend/docker-compose.yaml b/backend/docker-compose.yaml new file mode 100644 index 00000000..513bbc4e --- /dev/null +++ b/backend/docker-compose.yaml @@ -0,0 +1,17 @@ +version: "3" + +services: + db: + image: postgres + environment: + - POSTGRES_DB=postgres + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + ports: + - "5432:5432" + volumes: + - ./postgres:/var/lib/postgresql/data + redis: + image: redis:4.0 + ports: + - "6379:6379" diff --git a/backend/fly.toml b/backend/fly.toml new file mode 100644 index 00000000..11ff43e1 --- /dev/null +++ b/backend/fly.toml @@ -0,0 +1,43 @@ +# fly.toml file generated for office-hours-queue-asgi on 2023-02-06T22:08:16-05:00 + +app = "office-hours-queue-asgi" +kill_signal = "SIGINT" +kill_timeout = 5 +processes = [] + +[env] +# TODO: Populate this + #REDIS_URL = + +[build] +# TODO: GITSHA injection + dockerfile = "Dockerfile" + +[experimental] + auto_rollback = true + +[[services]] + http_checks = [] + internal_port = 8080 + processes = ["app"] + protocol = "tcp" + script_checks = [] + [services.concurrency] + hard_limit = 25 + soft_limit = 20 + type = "connections" + + [[services.ports]] + force_https = true + handlers = ["http"] + port = 80 + + [[services.ports]] + handlers = ["tls", "http"] + port = 443 + + [[services.tcp_checks]] + grace_period = "1s" + interval = "15s" + restart_limit = 0 + timeout = "2s" diff --git a/backend/manage.py b/backend/manage.py new file mode 100755 index 00000000..a76bae01 --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "officehoursqueue.settings.development") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/backend/officehoursqueue/__init__.py b/backend/officehoursqueue/__init__.py new file mode 100644 index 00000000..e11d55e4 --- /dev/null +++ b/backend/officehoursqueue/__init__.py @@ -0,0 +1,4 @@ +from officehoursqueue.celery import app as celery_app + + +__all__ = ("celery_app",) diff --git a/backend/officehoursqueue/asgi.py b/backend/officehoursqueue/asgi.py new file mode 100644 index 00000000..d273b76a --- /dev/null +++ b/backend/officehoursqueue/asgi.py @@ -0,0 +1,18 @@ +""" +ASGI config for officehoursqueue project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/ +""" + +import os + +import django +from channels.routing import get_default_application + + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "officehoursqueue.settings.production") +django.setup() +application = get_default_application() diff --git a/backend/officehoursqueue/celery.py b/backend/officehoursqueue/celery.py new file mode 100644 index 00000000..984a4c8c --- /dev/null +++ b/backend/officehoursqueue/celery.py @@ -0,0 +1,19 @@ +import os + +from celery import Celery +from django.conf import settings + + +# set the default Django settings module for the 'celery' program. +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "officehoursqueue.settings.development") + +app = Celery("officehoursqueue", broker=settings.MESSAGE_BROKER_URL) + +# Using a string here means the worker doesn't have to serialize +# the configuration object to child processes. +# - namespace='CELERY' means all celery-related configuration keys +# should have a `CELERY_` prefix. +app.config_from_object("django.conf:settings", namespace="CELERY") + +# Load task modules from all registered Django app configs. +app.autodiscover_tasks() diff --git a/backend/officehoursqueue/routing.py b/backend/officehoursqueue/routing.py new file mode 100644 index 00000000..bf420c8d --- /dev/null +++ b/backend/officehoursqueue/routing.py @@ -0,0 +1,17 @@ +from channels.auth import AuthMiddlewareStack +from channels.routing import ProtocolTypeRouter, URLRouter +from channels.security.websocket import AllowedHostsOriginValidator + +import ohq.routing +import ohq.urls # DO NOT DELETE THIS IMPORT! + + +# Django REST Live requires urls too be imported from the async entrypoint. + +application = ProtocolTypeRouter( + { + "websocket": AllowedHostsOriginValidator( + AuthMiddlewareStack(URLRouter(ohq.routing.websocket_urlpatterns)) + ) + } +) diff --git a/backend/officehoursqueue/settings/__init__.py b/backend/officehoursqueue/settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/officehoursqueue/settings/base.py b/backend/officehoursqueue/settings/base.py new file mode 100644 index 00000000..682960fb --- /dev/null +++ b/backend/officehoursqueue/settings/base.py @@ -0,0 +1,187 @@ +""" +Django settings for officehoursqueue. + +Generated by 'django-admin startproject' using Django 3.0. + +For more information on this file, see +https://docs.djangoproject.com/en/3.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/3.0/ref/settings/ +""" + +import os + +import dj_database_url + + +DOMAINS = os.environ.get("DOMAINS", "example.com").split(",") + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.environ.get("SECRET_KEY", "e7*yzg-4llxfw(dxvwko9tfy#y(n@t&n4wl^mcxkdh+o4qo-sr") + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = ["*"] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "corsheaders", + "rest_framework", + "django_filters", + "phonenumber_field", + "channels", + "rest_live.apps.RestLiveConfig", + "email_tools.apps.EmailToolsConfig", + "accounts.apps.AccountsConfig", + "ohq.apps.OhqConfig", + "schedule", +] + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "accounts.middleware.OAuth2TokenMiddleware", +] + +ROOT_URLCONF = "officehoursqueue.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": ["officehoursqueue/templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ] + }, + } +] + +ASGI_APPLICATION = "officehoursqueue.routing.application" + +# Database +# https://docs.djangoproject.com/en/3.0/ref/settings/#databases + +DATABASES = { + "default": dj_database_url.config( + default="postgres://postgres:postgres@localhost:5432/postgres" + ) +} + + +# Password validation +# https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + + +# Authentication Backends + +AUTHENTICATION_BACKENDS = [ + "ohq.backends.OHQBackend", + "django.contrib.auth.backends.ModelBackend", +] + + +# Internationalization +# https://docs.djangoproject.com/en/3.0/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.0/howto/static-files/ + +STATIC_URL = "/assets/" +STATIC_ROOT = os.path.join(BASE_DIR, "static") + + +# Rest Framework +REST_FRAMEWORK = { + "DEFAULT_RENDERER_CLASSES": ( + "djangorestframework_camel_case.render.CamelCaseJSONRenderer", + "djangorestframework_camel_case.render.CamelCaseBrowsableAPIRenderer", + # Any other renders + ), + "DEFAULT_PARSER_CLASSES": ( + # If you use MultiPartFormParser or FormParser, we also have a camel case version + "djangorestframework_camel_case.parser.CamelCaseFormParser", + "djangorestframework_camel_case.parser.CamelCaseMultiPartParser", + "djangorestframework_camel_case.parser.CamelCaseJSONParser", + # Any other parsers + ), + "TEST_REQUEST_DEFAULT_FORMAT": "json", +} + +# DLA Settings + +PLATFORM_ACCOUNTS = { + "REDIRECT_URI": os.environ.get("LABS_REDIRECT_URI", "http://localhost:8000/accounts/callback/"), + "CLIENT_ID": "clientid", + "CLIENT_SECRET": "supersecretclientsecret", + "PLATFORM_URL": "https://platform-dev.pennlabs.org", + "CUSTOM_ADMIN": False, +} + +# Email Settings + +EMAIL_TOOLS = { + "FROM_EMAIL": f"Office Hours Queue ", + "TEMPLATE_DIRECTORY": os.path.join(BASE_DIR, "officehoursqueue", "templates", "emails"), +} + +# Twilio Settings + +TWILIO_SID = os.environ.get("TWILIO_SID", "") +TWILIO_AUTH_TOKEN = os.environ.get("TWILIO_TOKEN", "") +TWILIO_NUMBER = os.environ.get("TWILIO_NUMBER", "") + +# Redis URL used for celery, channels and general caching. +REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost") + +# Celery + +MESSAGE_BROKER_URL = REDIS_URL + +# Default to in-memory Channel Layer for dev and CI. + +CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}} diff --git a/backend/officehoursqueue/settings/ci.py b/backend/officehoursqueue/settings/ci.py new file mode 100644 index 00000000..0f69c9fa --- /dev/null +++ b/backend/officehoursqueue/settings/ci.py @@ -0,0 +1,6 @@ +from officehoursqueue.settings.base import * # noqa: F401, F403 + + +TEST_RUNNER = "xmlrunner.extra.djangotestrunner.XMLTestRunner" +TEST_OUTPUT_VERBOSE = 2 +TEST_OUTPUT_DIR = "test-results" diff --git a/backend/officehoursqueue/settings/development.py b/backend/officehoursqueue/settings/development.py new file mode 100644 index 00000000..4a3f596f --- /dev/null +++ b/backend/officehoursqueue/settings/development.py @@ -0,0 +1,17 @@ +import os + +from officehoursqueue.settings.base import * # noqa: F401, F403 +from officehoursqueue.settings.base import INSTALLED_APPS, MIDDLEWARE + + +# Development extensions +INSTALLED_APPS += ["django_extensions", "debug_toolbar"] + +MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware"] + MIDDLEWARE +INTERNAL_IPS = ["127.0.0.1"] + +# Allow http callback for DLA +os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" + +# Use the console for email in development +EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" diff --git a/backend/officehoursqueue/settings/production.py b/backend/officehoursqueue/settings/production.py new file mode 100644 index 00000000..7962d0e4 --- /dev/null +++ b/backend/officehoursqueue/settings/production.py @@ -0,0 +1,42 @@ +import os + +import sentry_sdk +from sentry_sdk.integrations.celery import CeleryIntegration +from sentry_sdk.integrations.django import DjangoIntegration + +from officehoursqueue.settings.base import * # noqa: F401, F403 +from officehoursqueue.settings.base import DOMAINS, REDIS_URL + + +DEBUG = False + +# Honour the 'X-Forwarded-Proto' header for request.is_secure() +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +# Allow production host headers +ALLOWED_HOSTS = DOMAINS + +# Make sure SECRET_KEY is set to a secret in production +SECRET_KEY = os.environ.get("SECRET_KEY", None) + +# Sentry settings +SENTRY_URL = os.environ.get("SENTRY_URL", "") +sentry_sdk.init(dsn=SENTRY_URL, integrations=[CeleryIntegration(), DjangoIntegration()]) + +# DLA settings +PLATFORM_ACCOUNTS = {"ADMIN_PERMISSION": "ohq_admin"} + +# Email client settings +EMAIL_HOST = os.getenv("SMTP_HOST") +EMAIL_PORT = int(os.getenv("SMTP_PORT", 587)) +EMAIL_HOST_USER = os.getenv("SMTP_USERNAME") +EMAIL_HOST_PASSWORD = os.getenv("SMTP_PASSWORD") +EMAIL_USE_TLS = True + +# Redis Channel Layer +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": {"hosts": [REDIS_URL]}, + }, +} diff --git a/backend/officehoursqueue/settings/staging.py b/backend/officehoursqueue/settings/staging.py new file mode 100644 index 00000000..0de07f7b --- /dev/null +++ b/backend/officehoursqueue/settings/staging.py @@ -0,0 +1 @@ +from officehoursqueue.settings.base import * # noqa: F401, F403 diff --git a/backend/officehoursqueue/templates/emails/base.html b/backend/officehoursqueue/templates/emails/base.html new file mode 100644 index 00000000..8bb46069 --- /dev/null +++ b/backend/officehoursqueue/templates/emails/base.html @@ -0,0 +1,79 @@ + + + + + + + + + + + + + +
+ {% block content %}{% endblock %} +
+ + diff --git a/backend/officehoursqueue/templates/emails/course_added.html b/backend/officehoursqueue/templates/emails/course_added.html new file mode 100644 index 00000000..9c0b54c2 --- /dev/null +++ b/backend/officehoursqueue/templates/emails/course_added.html @@ -0,0 +1,19 @@ +{% extends 'emails/base.html' %} + +{% block content %} + +

You've been added to {{ course }} OHQ

+ +

You have been added to {{ course }} as a {{ role }}.

+ +

Click below to view this course's office hours queues!

+ + +{% endblock %} diff --git a/backend/officehoursqueue/templates/emails/course_invitation.html b/backend/officehoursqueue/templates/emails/course_invitation.html new file mode 100644 index 00000000..769b9976 --- /dev/null +++ b/backend/officehoursqueue/templates/emails/course_invitation.html @@ -0,0 +1,19 @@ +{% extends 'emails/base.html' %} + +{% block content %} + +

Invitation to join {{ course }} OHQ

+ +

You have been invited to join {{ course }} as a {{ role }}.

+ +

Click below to create your account!

+ + +{% endblock %} diff --git a/backend/officehoursqueue/templates/redoc.html b/backend/officehoursqueue/templates/redoc.html new file mode 100644 index 00000000..c02bbf97 --- /dev/null +++ b/backend/officehoursqueue/templates/redoc.html @@ -0,0 +1,21 @@ + + + + ReDoc + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/officehoursqueue/urls.py b/backend/officehoursqueue/urls.py new file mode 100644 index 00000000..bb5ecc12 --- /dev/null +++ b/backend/officehoursqueue/urls.py @@ -0,0 +1,43 @@ +from django.conf import settings +from django.contrib import admin +from django.urls import include, path +from django.views.generic import TemplateView +from djangorestframework_camel_case.render import CamelCaseJSONRenderer +from rest_framework.schemas import get_schema_view + + +admin.site.site_header = "Office Hours Queue Admin" + +urlpatterns = [ + path("", include("ohq.urls")), + path("accounts/", include("accounts.urls", namespace="accounts")), + path( + "openapi/", + get_schema_view( + title="Office Hours Queue Documentation", + public=True, + renderer_classes=[CamelCaseJSONRenderer], + ), + name="openapi-schema", + ), + path( + "documentation/", + TemplateView.as_view( + template_name="redoc.html", extra_context={"schema_url": "openapi-schema"} + ), + name="documentation", + ), +] + +urlpatterns = [ + path("api/", include(urlpatterns)), + path("admin/", admin.site.urls), +] + +if settings.DEBUG: # pragma: no cover + import debug_toolbar + + urlpatterns = [ + path("__debug__/", include(debug_toolbar.urls)), + path("emailpreview/", include("email_tools.urls")), + ] + urlpatterns diff --git a/backend/officehoursqueue/wsgi.py b/backend/officehoursqueue/wsgi.py new file mode 100644 index 00000000..24bea00e --- /dev/null +++ b/backend/officehoursqueue/wsgi.py @@ -0,0 +1,17 @@ +""" +WSGI config for officehoursqueue project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "officehoursqueue.settings.production") + +application = get_wsgi_application() diff --git a/backend/ohq/__init__.py b/backend/ohq/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/ohq/admin.py b/backend/ohq/admin.py new file mode 100644 index 00000000..ac8c5d60 --- /dev/null +++ b/backend/ohq/admin.py @@ -0,0 +1,28 @@ +from django.contrib import admin + +from ohq.models import ( + Announcement, + Course, + CourseStatistic, + Membership, + MembershipInvite, + Profile, + Question, + Queue, + QueueStatistic, + Semester, + Tag, +) + + +admin.site.register(Course) +admin.site.register(CourseStatistic) +admin.site.register(Membership) +admin.site.register(MembershipInvite) +admin.site.register(Profile) +admin.site.register(Question) +admin.site.register(Queue) +admin.site.register(Semester) +admin.site.register(QueueStatistic) +admin.site.register(Announcement) +admin.site.register(Tag) diff --git a/backend/ohq/apps.py b/backend/ohq/apps.py new file mode 100644 index 00000000..0d3eb161 --- /dev/null +++ b/backend/ohq/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class OhqConfig(AppConfig): + name = "ohq" diff --git a/backend/ohq/backends.py b/backend/ohq/backends.py new file mode 100644 index 00000000..9a5216bc --- /dev/null +++ b/backend/ohq/backends.py @@ -0,0 +1,19 @@ +from accounts.backends import LabsUserBackend + +from ohq.models import Membership, MembershipInvite + + +class OHQBackend(LabsUserBackend): + """ + A custom DLA backend that converts Membership Invites into Memberships on user creation. + """ + + def post_authenticate(self, user, created, dictionary): + if created: + invites = MembershipInvite.objects.filter(email__istartswith=f"{user.username}@") + + for invite in invites: + Membership.objects.create(course=invite.course, kind=invite.kind, user=user) + + invites.delete() + user.save() diff --git a/backend/ohq/filters.py b/backend/ohq/filters.py new file mode 100644 index 00000000..06f71963 --- /dev/null +++ b/backend/ohq/filters.py @@ -0,0 +1,35 @@ +from django.db.models import Q +from django_filters import rest_framework as filters + +from ohq.models import CourseStatistic, Question, QueueStatistic + + +class QuestionSearchFilter(filters.FilterSet): + # time_asked = filters.DateFilter(lookup_expr="icontains") + search = filters.CharFilter(method="search_filter") + order_by = filters.OrderingFilter(fields=["time_asked"]) + + class Meta: + model = Question + fields = {"time_asked": ["gt", "lt"], "queue": ["exact"], "status": ["exact"]} + + def search_filter(self, queryset, name, value): + return queryset.filter( + Q(text__icontains=value) + | Q(asked_by__first_name__icontains=value) + | Q(asked_by__last_name__icontains=value) + | Q(responded_to_by__first_name__icontains=value) + | Q(responded_to_by__last_name__icontains=value) + ) + + +class CourseStatisticFilter(filters.FilterSet): + class Meta: + model = CourseStatistic + fields = ["metric", "date"] + + +class QueueStatisticFilter(filters.FilterSet): + class Meta: + model = QueueStatistic + fields = {"metric": ["exact"], "date": ["gt", "lt", "gte", "lte", "exact"]} diff --git a/backend/ohq/invite.py b/backend/ohq/invite.py new file mode 100644 index 00000000..c57f9bdb --- /dev/null +++ b/backend/ohq/invite.py @@ -0,0 +1,54 @@ +from django.contrib.auth import get_user_model +from django.core.validators import validate_email +from django.db.models import Q + +from ohq.models import Membership, MembershipInvite + + +User = get_user_model() + + +def parse_and_send_invites(course, emails, kind): + """ + Take in a list of emails, validate them. Then: + 1. Create memberships for emails that belong to an existing user + 2. Send out membership invites to the remaining emails + """ + + # Validate emails + for email in emails: + validate_email(email) + + # Map of pennkey to invite email (which may be different from the user's email) + email_map = {email.split("@")[0]: email for email in emails} + + # Remove invitees already in class + existing = Membership.objects.filter( + course=course, user__username__in=email_map.keys() + ).values_list("user__username", flat=True) + existing = [email_map[pennkey] for pennkey in existing] + + emails = list(set(emails) - set(existing)) + + # Remove users already invited + existing = MembershipInvite.objects.filter(course=course, email__in=emails).values_list( + "email", flat=True + ) + emails = list(set(emails) - set(existing)) + + # Generate final map of pennkey to email of users that need to be invited + email_map = {email.split("@")[0]: email for email in emails} + + # Directly add invitees with existing accounts + users = User.objects.filter(Q(email__in=emails) | Q(username__in=email_map.keys())).distinct() + for user in users: + membership = Membership.objects.create(course=course, user=user, kind=kind) + membership.send_email() + del email_map[user.username] + + # Create membership invites for invitees without an account + for email in email_map.values(): + invite = MembershipInvite.objects.create(email=email, course=course, kind=kind) + invite.send_email() + + return (users.count(), len(email_map)) diff --git a/backend/ohq/management/__init__.py b/backend/ohq/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/ohq/management/commands/__init__.py b/backend/ohq/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/ohq/management/commands/archive.py b/backend/ohq/management/commands/archive.py new file mode 100644 index 00000000..304efcf7 --- /dev/null +++ b/backend/ohq/management/commands/archive.py @@ -0,0 +1,24 @@ +from django.core.management.base import BaseCommand + +from ohq.models import Course, Semester + + +class Command(BaseCommand): + help = "Creates a course with default settings and invites users to course" + + def add_arguments(self, parser): + parser.add_argument( + "term", type=str, choices=[choice[0] for choice in Semester.TERM_CHOICES] + ) + parser.add_argument("year", type=int) + + def handle(self, *args, **kwargs): + term = kwargs["term"] + year = kwargs["year"] + + courses = Course.objects.filter(semester__year=year, semester__term=term) + for course in courses: + course.archived = True + course.save() + + self.stdout.write(f"{len(courses)} course(s) archived") diff --git a/backend/ohq/management/commands/calculatewaittimes.py b/backend/ohq/management/commands/calculatewaittimes.py new file mode 100644 index 00000000..1e45a895 --- /dev/null +++ b/backend/ohq/management/commands/calculatewaittimes.py @@ -0,0 +1,11 @@ +from django.core.management.base import BaseCommand + +from ohq.queues import calculate_wait_times + + +class Command(BaseCommand): + help = "Calculates the estimated wait times of all unarchived queues." + + def handle(self, *args, **kwargs): + calculate_wait_times() + self.stdout.write("Updated estimated queue wait times!") diff --git a/backend/ohq/management/commands/course_stat.py b/backend/ohq/management/commands/course_stat.py new file mode 100644 index 00000000..62c44a9f --- /dev/null +++ b/backend/ohq/management/commands/course_stat.py @@ -0,0 +1,52 @@ +from django.core.management.base import BaseCommand +from django.utils import timezone + +from ohq.models import Course, Question +from ohq.statistics import ( + course_calculate_instructor_most_questions_answered, + course_calculate_instructor_most_time_helping, + course_calculate_student_most_questions_asked, + course_calculate_student_most_time_being_helped, +) + + +class Command(BaseCommand): + def add_arguments(self, parser): + parser.add_argument("--hist", action="store_true", help="Calculate all historic statistics") + + def calculate_statistics(self, courses, earliest_date): + yesterday = timezone.datetime.today().date() - timezone.timedelta(days=1) + + for course in courses: + if earliest_date: + iter_date = earliest_date + else: + course_questions = Question.objects.filter(queue__course=course) + iter_date = ( + timezone.template_localtime( + course_questions.earliest("time_asked").time_asked + ).date() + if course_questions + else yesterday + ) + + # weekday() - monday is 0, sunday is 6 => we want last sunday + iter_date = iter_date - timezone.timedelta(days=(iter_date.weekday() + 1) % 7) + + while iter_date <= yesterday: + course_calculate_student_most_questions_asked(course, iter_date) + course_calculate_student_most_time_being_helped(course, iter_date) + course_calculate_instructor_most_questions_answered(course, iter_date) + course_calculate_instructor_most_time_helping(course, iter_date) + + iter_date += timezone.timedelta(days=7) + + def handle(self, *args, **kwargs): + if kwargs["hist"]: + courses = Course.objects.all() + earliest_date = None + else: + courses = Course.objects.filter(archived=False) + earliest_date = timezone.datetime.today().date() - timezone.timedelta(days=1) + + self.calculate_statistics(courses, earliest_date) diff --git a/backend/ohq/management/commands/createcourse.py b/backend/ohq/management/commands/createcourse.py new file mode 100644 index 00000000..dc6bc405 --- /dev/null +++ b/backend/ohq/management/commands/createcourse.py @@ -0,0 +1,58 @@ +from django.core.management.base import BaseCommand, CommandError + +from ohq.invite import parse_and_send_invites +from ohq.models import Course, Membership, Semester + + +class Command(BaseCommand): + help = "Creates a course with default settings and invites users to course" + + def add_arguments(self, parser): + parser.add_argument("department", type=str) + parser.add_argument("course_code", type=str) + parser.add_argument("course_title", type=str) + parser.add_argument( + "term", type=str, choices=[choice[0] for choice in Semester.TERM_CHOICES] + ) + parser.add_argument("year", type=int) + parser.add_argument("--emails", nargs="+", type=str) + parser.add_argument( + "--roles", nargs="+", choices=[Membership.KIND_PROFESSOR, Membership.KIND_HEAD_TA], + ) + + def handle(self, *args, **kwargs): + course_code = kwargs["course_code"] + department = kwargs["department"] + course_title = kwargs["course_title"] + term = kwargs["term"] + year = kwargs["year"] + emails = kwargs["emails"] + roles = kwargs["roles"] + + if len(emails) != len(roles): + raise CommandError("Length of emails and roles do not match") + + semester = Semester.objects.get(year=year, term=term) + new_course = Course.objects.create( + course_code=course_code, + department=department, + course_title=course_title, + semester=semester, + ) + + self.stdout.write(f"Created new course '{new_course}'") + + role_map = {email: role for role, email in zip(roles, emails)} + + groups = {Membership.KIND_PROFESSOR: [], Membership.KIND_HEAD_TA: []} + for email in emails: + groups[role_map[email]].append(email) + + added, invited = parse_and_send_invites( + new_course, groups[Membership.KIND_PROFESSOR], Membership.KIND_PROFESSOR + ) + self.stdout.write(f"Added {added} professor(s) and invited {invited} professor(s)") + added, invited = parse_and_send_invites( + new_course, groups[Membership.KIND_HEAD_TA], Membership.KIND_HEAD_TA + ) + self.stdout.write(f"Added {added} Head TA(s) and invited {invited} Head TA(s)") diff --git a/backend/ohq/management/commands/populate.py b/backend/ohq/management/commands/populate.py new file mode 100644 index 00000000..598d3a0e --- /dev/null +++ b/backend/ohq/management/commands/populate.py @@ -0,0 +1,465 @@ +import datetime + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.core.management import call_command +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone + +from ohq.models import Course, Membership, MembershipInvite, Profile, Question, Queue, Semester + + +now = timezone.now() + +# creating data for 6 courses, 8 queues, 10 questions +courses = [ + { + "course_code": "199", + "department": "CIS", + "course_title": "Kevin's Kevin", + "description": "Examining the wonders of Kevin.", + "queues": [ + { + "name": "Kevin-related Questions", + "description": "Have a question about Kevin? Ask here!", + "question_template": "Favorite leg of Kevin: \nFavorite arm of Kevin:", + "archived": False, + "estimated_wait_time": 100, + "active": True, + "questions": [ + { + "text": "How many joints does Kevin's leg have?", + "video_chat_url": "https://upenn.zoom.us/j/jfawdjf308220740182aldskjf", + "status": Question.STATUS_ACTIVE, + "time_asked": now - datetime.timedelta(minutes=48), + "time_response_started": now - datetime.timedelta(minutes=30), + "should_send_up_soon_notification": True, + }, + ], + "video_chat_setting": Queue.VIDEO_REQUIRED, + }, + ], + "semester": {"year": 2020, "term": Semester.TERM_FALL}, + "archived": False, + "invite_only": False, + }, + { + "course_code": "101", + "department": "OIDD", + "course_title": "Code Coverage", + "description": "Professor Davis shows the entire Penn Labs team how to code coverage :)", + "queues": [ + { + "name": "Questions that Armaan has for Davis", + "description": "Code Coverage is important!", + "question_template": "", + "archived": False, + "estimated_wait_time": 1, + "active": True, + "questions": [ + { + "text": "How do I code coverage, almight Davis?", + "video_chat_url": "https://upenn.zoom.us/j/adkjfaqiowdskjf", + "status": Question.STATUS_REJECTED, + "time_asked": now - datetime.timedelta(minutes=121), + "time_response_started": now - datetime.timedelta(minutes=12), + "time_responded_to": now - datetime.timedelta(minutes=12), + "rejected_reason": "NOT_SPECIFIC", + "should_send_up_soon_notification": False, + }, + ], + "video_chat_setting": Queue.VIDEO_REQUIRED, + }, + ], + "semester": {"year": 2020, "term": Semester.TERM_SUMMER}, + "archived": False, + "invite_only": False, + }, + { + "course_code": "200", + "department": "ART", + "course_title": "The Crayola Perspective", + "description": "Crayons! Markers! Learn to use these technical tools and gain new skills!", + "queues": [], + "semester": {"year": 2020, "term": Semester.TERM_FALL}, + "archived": False, + "invite_only": True, + }, + { + "course_code": "700", + "department": "WH", + "course_title": "Snake City", + "description": "Free internships at Goldman Sachs. We've got Morgan Stanley too.", + "queues": [ + { + "name": "Goldman Sachs Questions", + "description": "Free as in easy to get into? Or free as in I don't get paid?", + "question_template": "How much money are you willing to sell yourself out for: ", + "archived": False, + "estimated_wait_time": 10, + "active": True, + "questions": [ + { + "text": "How snakey are Goldman people?", + "video_chat_url": "https://upenn.zoom.us/j/adadsf12313", + "status": Question.STATUS_ACTIVE, + "time_asked": now - datetime.timedelta(minutes=11), + "time_response_started": now - datetime.timedelta(minutes=5), + "should_send_up_soon_notification": False, + }, + { + "text": "Can I get the recruiter's email?", + "video_chat_url": "https://upenn.zoom.us/j/ilovegoldmansachsplz", + "status": Question.STATUS_ASKED, + "time_asked": now - datetime.timedelta(minutes=20), + "should_send_up_soon_notification": False, + }, + { + "text": "How much money do I get?", + "video_chat_url": "https://upenn.zoom.us/j/hellof", + "status": Question.STATUS_ANSWERED, + "time_asked": now - datetime.timedelta(minutes=40), + "time_response_started": now - datetime.timedelta(minutes=10), + "time_responded_to": now - datetime.timedelta(minutes=1), + "should_send_up_soon_notification": False, + }, + ], + "video_chat_setting": Queue.VIDEO_REQUIRED, + }, + { + "name": "Morgan Stanley Questions", + "description": "Wall Street 2 EZ", + "question_template": "", + "archived": False, + "estimated_wait_time": 0, + "active": True, + "questions": [], + "video_chat_setting": Queue.VIDEO_REQUIRED, + }, + { + "name": "Questions for Rejects - Will always be closed", + "description": "Drop the class", + "question_template": "", + "archived": False, + "estimated_wait_time": 0, + "active": False, + "questions": [], + "video_chat_setting": Queue.VIDEO_REQUIRED, + }, + ], + "semester": {"year": 2020, "term": Semester.TERM_SUMMER}, + "archived": False, + "invite_only": False, + }, + { + "course_code": "621", + "department": "LABS", + "course_title": "Penn Labs", + "description": "Honestly just the best club ever.", + "queues": [ + { + "name": "Vibing Questions", + "description": "Good Vibes? Join Here", + "question_template": "", + "archived": False, + "estimated_wait_time": 20, + "active": True, + "questions": [ + { + "text": "How good are you guys?", + "video_chat_url": "https://upenn.zoom.us/j/adad13", + "status": Question.STATUS_WITHDRAWN, + "time_asked": now - datetime.timedelta(minutes=1), + "should_send_up_soon_notification": False, + }, + { + "text": "I needa go pee.", + "video_chat_url": "https://upenn.zoom.us/j/dfsgfdsgfddskjf", + "status": Question.STATUS_REJECTED, + "time_asked": now - datetime.timedelta(minutes=91), + "time_response_started": now - datetime.timedelta(minutes=2), + "time_responded_to": now - datetime.timedelta(minutes=2), + "rejected_reason": "NOT_HERE", + "should_send_up_soon_notification": True, + }, + { + "text": "Ying where's my DaRK mOdE", + "video_chat_url": "https://upenn.zoom.us/j/armaanTakeMyChildren", + "status": Question.STATUS_ACTIVE, + "time_asked": now - datetime.timedelta(minutes=400), + "time_response_started": now - datetime.timedelta(minutes=130), + "should_send_up_soon_notification": False, + }, + ], + "video_chat_setting": Queue.VIDEO_REQUIRED, + }, + { + "name": "Other Questions", + "description": "Questions for not so good vibes", + "question_template": "", + "archived": False, + "estimated_wait_time": 30, + "active": True, + "questions": [ + { + "text": "Potatoes", + "video_chat_url": "https://upenn.zoom.us/j/ad123456sdfghd13", + "status": Question.STATUS_WITHDRAWN, + "time_asked": now - datetime.timedelta(minutes=1), + "should_send_up_soon_notification": False, + }, + { + "text": "How's life at the Labs?", + "video_chat_url": "https://upenn.zoom.us/j/daaaarrkkkkkMooodddeee", + "status": Question.STATUS_ANSWERED, + "time_asked": now - datetime.timedelta(minutes=240), + "time_response_started": now - datetime.timedelta(minutes=50), + "time_responded_to": now - datetime.timedelta(minutes=17), + "should_send_up_soon_notification": True, + }, + ], + "video_chat_setting": Queue.VIDEO_REQUIRED, + }, + { + "name": "We are archiving the bad vibes", + "description": "No bad vibes allowed", + "question_template": "", + "archived": True, + "estimated_wait_time": 0, + "active": False, + "questions": [], + "video_chat_setting": Queue.VIDEO_REQUIRED, + }, + ], + "semester": {"year": 2020, "term": Semester.TERM_SPRING}, + "archived": False, + "invite_only": True, + }, + { + "course_code": "500", + "department": "OLD", + "course_title": "Class of 1930", + "description": "Class of 1930 Graduates Only! Likely to be archived", + "semester": {"year": 1930, "term": Semester.TERM_SPRING}, + "archived": True, + "queues": [], + "invite_only": False, + }, +] + + +class Command(BaseCommand): + help = "Populates the development environment with dummy data." + + def handle(self, *args, **kwargs): + + if not settings.DEBUG: + raise CommandError("You probably do not want to run this script in production!") + + # create 11 users + # (1 prof, 2 head TA's, 2 TA's, 4 students, 1 invites, and 1 not in the course) + count = 0 + schools = ["seas", "nursing", "wharton", "sas"] + users = [ + "Benjamin Franklin", + "George Washington", + "John Adams", + "Thomas Jefferson", + "James Madison", + "James Monroe", + "John Quincy Adams", + "Andrew Jackson", + "Kevin Chen", + "Justin Zhang", + "Armaan Davis", + ] + user_objs = [] + for user in users: + first, last = user.split(" ", 1) + last = last.replace(" ", "") + username = "{}{}".format(first[0], last).lower() + email = "{}@{}.upenn.edu".format(username, schools[count % len(schools)]) + count += 1 + User = get_user_model() + if User.objects.filter(username=username).exists(): + user_objs.append(User.objects.get(username=username)) + else: + obj = User.objects.create_user(username, email, "test") + obj.first_name = first + obj.last_name = last + obj.is_staff = True + obj.set_password("pennlabs") + obj.save() + user_objs.append(obj) + + # create TJeff as superuser + tjeff = user_objs[3] + tjeff.is_superuser = True + tjeff.save() + + # create profiles for each user + for i in range(len(user_objs)): + code = str(i) + "code" + phone_number = "+1234567890" + str(i)[0] + if i % 5 == 0: + # create combo 1 - no sms verification, notifications not enabled + newProfile = Profile.objects.get(user=user_objs[i]) + newProfile.phone_number = phone_number + if i % 5 == 1: + # create combo 2 - sms verfication pending, notifications disabled + newProfile = Profile.objects.get(user=user_objs[i]) + newProfile.sms_verification_code = code + newProfile.phone_number = phone_number + if i % 5 == 2: + # create combo 3 - sms verified and sms notifications off + newProfile = Profile.objects.get(user=user_objs[i]) + newProfile.sms_verification_code = code + newProfile.sms_verification_timestamp = now - datetime.timedelta(minutes=i * 10) + newProfile.sms_verified = True + newProfile.phone_number = phone_number + if i % 5 == 3: + # create combo 4 - sms verfied and notifications on + newProfile = Profile.objects.get(user=user_objs[i]) + newProfile.sms_verification_code = code + newProfile.sms_notifications_enabled = True + newProfile.sms_verification_timestamp = now - datetime.timedelta(minutes=i * 10) + newProfile.sms_verified = True + newProfile.phone_number = phone_number + + newProfile.save() + + # create courses + newCount = 0 + for info in courses: + partial = dict(info) + custom_fields = [ + "course_code", + "queues", + ] + for field in custom_fields: + if field in partial: + del partial[field] + + partial["semester"], _ = Semester.objects.get_or_create( + year=partial["semester"]["year"], term=partial["semester"]["term"] + ) + + # created the course with everything except for memberships and membership invites + newCourse, _ = Course.objects.get_or_create( + course_code=info["course_code"], defaults=partial + ) + + # create 1 professor + professorMembership, _ = Membership.objects.get_or_create( + course=newCourse, + user=user_objs[newCount % len(user_objs)], + kind=Membership.KIND_PROFESSOR, + ) + newCount += 1 + + # create 2 head TAs + headTAList = [] + for i in range(2): + + headTAMembership, _ = Membership.objects.get_or_create( + course=newCourse, + user=user_objs[(newCount) % len(user_objs)], + kind=Membership.KIND_HEAD_TA, + ) + headTAList.append(headTAMembership) + newCount += 1 + + if i == 1: + headTAMembership.last_active = now - datetime.timedelta(minutes=2) + headTAMembership.save() + + # create 2 regular TA's + regularTAList = [] + for i in range(2): + regularTAMembership, _ = Membership.objects.get_or_create( + course=newCourse, + user=user_objs[(newCount) % len(user_objs)], + kind=Membership.KIND_TA, + ) + regularTAList.append(regularTAMembership) + newCount += 1 + + if i == 1 or i == 3: + regularTAMembership.last_active = now - datetime.timedelta(minutes=i) + regularTAMembership.save() + + # list with head TA's and regular TA's + totalTAList = headTAList + regularTAList + + # create 4 students + studentList = [] + for i in range(4): + studentMembership, _ = Membership.objects.get_or_create( + course=newCourse, + user=user_objs[(newCount) % len(user_objs)], + kind=Membership.KIND_STUDENT, + ) + studentList.append(studentMembership) + newCount += 1 + + # create an invite per course + membership_invite, _ = MembershipInvite.objects.get_or_create( + email=user_objs[(newCount + 1) % len(user_objs)].email, + course=newCourse, + kind=Membership.KIND_CHOICES[(newCount + 1) % len(Membership.KIND_CHOICES)][0], + ) + + # create queues for each course + for q in info["queues"]: + newQueue, _ = Queue.objects.get_or_create( + name=q["name"], + description=q["description"], + question_template=q["question_template"], + course=newCourse, + archived=q["archived"], + estimated_wait_time=q["estimated_wait_time"], + active=q["active"], + video_chat_setting=q["video_chat_setting"], + ) + + respondedToCount = 0 + askedByCount = 0 + + # adding the questions to each queue + for ques in q["questions"]: + + newQuestion, _ = Question.objects.get_or_create( + text=ques["text"], + queue=newQueue, + video_chat_url=ques["video_chat_url"], + status=ques["status"], + asked_by=studentList[askedByCount % len(studentList)].user, + should_send_up_soon_notification=ques["should_send_up_soon_notification"], + ) + # prevent auto now add from changing the time + newQuestion.time_asked = ques["time_asked"] + + askedByCount += 1 + + # accounting for different question status + if ques["status"] in [ + Question.STATUS_REJECTED, + Question.STATUS_ANSWERED, + Question.STATUS_ACTIVE, + ]: + newQuestion.time_response_started = ques["time_response_started"] + newQuestion.responded_to_by = totalTAList[ + respondedToCount % len(totalTAList) + ].user + respondedToCount += 1 + + if ques["status"] == Question.STATUS_REJECTED: + newQuestion.rejected_reason = ques["rejected_reason"] + + if ques["status"] in [Question.STATUS_ANSWERED, Question.STATUS_REJECTED]: + newQuestion.time_responded_to = ques["time_responded_to"] + + newQuestion.save() + + call_command("queue_daily_stat", "--hist") + call_command("queue_heatmap_stat", "--hist") diff --git a/backend/ohq/management/commands/queue_daily_stat.py b/backend/ohq/management/commands/queue_daily_stat.py new file mode 100644 index 00000000..c891e298 --- /dev/null +++ b/backend/ohq/management/commands/queue_daily_stat.py @@ -0,0 +1,51 @@ +from django.core.management.base import BaseCommand +from django.utils import timezone + +from ohq.models import Question, Queue +from ohq.statistics import ( + queue_calculate_avg_time_helping, + queue_calculate_avg_wait, + queue_calculate_num_questions_ans, + queue_calculate_num_students_helped, +) + + +class Command(BaseCommand): + def add_arguments(self, parser): + parser.add_argument("--hist", action="store_true", help="Calculate all historic statistics") + + def calculate_statistics(self, queues, earliest_date): + yesterday = timezone.datetime.today().date() - timezone.timedelta(days=1) + + for queue in queues: + queue_questions = Question.objects.filter(queue=queue) + + if earliest_date: + iter_date = earliest_date + else: + iter_date = ( + timezone.template_localtime( + queue_questions.earliest("time_asked").time_asked + ).date() + if queue_questions + else yesterday + ) + + while iter_date <= yesterday: + + queue_calculate_avg_wait(queue, iter_date) + queue_calculate_avg_time_helping(queue, iter_date) + queue_calculate_num_questions_ans(queue, iter_date) + queue_calculate_num_students_helped(queue, iter_date) + + iter_date += timezone.timedelta(days=1) + + def handle(self, *args, **kwargs): + if kwargs["hist"]: + queues = Queue.objects.all() + earliest_date = None + else: + queues = Queue.objects.filter(archived=False) + earliest_date = timezone.datetime.today().date() - timezone.timedelta(days=1) + + self.calculate_statistics(queues, earliest_date) diff --git a/backend/ohq/management/commands/queue_heatmap_stat.py b/backend/ohq/management/commands/queue_heatmap_stat.py new file mode 100644 index 00000000..d2683e26 --- /dev/null +++ b/backend/ohq/management/commands/queue_heatmap_stat.py @@ -0,0 +1,36 @@ +from django.core.management.base import BaseCommand +from django.utils import timezone + +from ohq.models import Queue +from ohq.statistics import ( + queue_calculate_questions_per_ta_heatmap, + queue_calculate_wait_time_heatmap, +) + + +class Command(BaseCommand): + def add_arguments(self, parser): + parser.add_argument("--hist", action="store_true", help="Calculate all historic statistics") + + def calculate_statistics(self, queues, weekdays): + """ + Helper function to calculate the heatmap statistics + """ + for queue in queues: + for weekday in weekdays: + for hour in range(24): + queue_calculate_questions_per_ta_heatmap(queue, weekday, hour) + queue_calculate_wait_time_heatmap(queue, weekday, hour) + + def handle(self, *args, **kwargs): + if kwargs["hist"]: + queues = Queue.objects.all() + weekdays = [i for i in range(1, 8)] + else: + queues = Queue.objects.filter(archived=False) + + # assuming the cron job runs at midnight, we only need to update yesterday's weekday + yesterday = timezone.datetime.today().date() - timezone.timedelta(days=1) + weekdays = [(yesterday.weekday() + 1) % 7 + 1] + + self.calculate_statistics(queues, weekdays) diff --git a/backend/ohq/migrations/0001_initial.py b/backend/ohq/migrations/0001_initial.py new file mode 100644 index 00000000..87f59828 --- /dev/null +++ b/backend/ohq/migrations/0001_initial.py @@ -0,0 +1,259 @@ +# Generated by Django 3.0.8 on 2020-07-30 03:22 + +import django.db.models.deletion +import phonenumber_field.modelfields +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] + + operations = [ + migrations.CreateModel( + name="Course", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("course_code", models.CharField(max_length=10)), + ("department", models.CharField(max_length=10)), + ("course_title", models.CharField(max_length=50)), + ("description", models.CharField(blank=True, max_length=255)), + ("archived", models.BooleanField(default=False)), + ("invite_only", models.BooleanField(default=False)), + ("video_chat_enabled", models.BooleanField(default=False)), + ("require_video_chat_url_on_questions", models.BooleanField(default=False)), + ], + ), + migrations.CreateModel( + name="Semester", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("year", models.IntegerField()), + ( + "term", + models.CharField( + choices=[ + ("SPRING", "Spring"), + ("SUMMER", "Summer"), + ("FALL", "Fall"), + ("WINTER", "Winter"), + ], + default="FALL", + max_length=6, + ), + ), + ], + ), + migrations.CreateModel( + name="Queue", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("name", models.CharField(max_length=255)), + ("description", models.TextField()), + ("archived", models.BooleanField(default=False)), + ("estimated_wait_time", models.IntegerField(default=0)), + ("active", models.BooleanField(default=False)), + ( + "course", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="ohq.Course"), + ), + ], + ), + migrations.CreateModel( + name="Question", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("text", models.TextField()), + ("video_chat_url", models.URLField(blank=True, null=True)), + ("time_asked", models.DateTimeField(auto_now_add=True)), + ("time_last_updated", models.DateTimeField(blank=True, null=True)), + ("time_withdrawn", models.DateTimeField(blank=True, null=True)), + ("time_rejected", models.DateTimeField(blank=True, null=True)), + ("rejected_reason", models.CharField(blank=True, max_length=20, null=True)), + ("rejected_reason_other", models.CharField(blank=True, max_length=200, null=True)), + ("time_started", models.DateTimeField(blank=True, null=True)), + ("time_answered", models.DateTimeField(blank=True, null=True)), + ("should_send_up_soon_notification", models.BooleanField(default=False)), + ( + "answered_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="answered_questions", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "asked_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="asked_questions", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "queue", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="ohq.Queue"), + ), + ( + "rejected_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="rejected_questions", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="Profile", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("sms_notifications_enabled", models.BooleanField(default=False)), + ("sms_verification_code", models.CharField(blank=True, max_length=6, null=True)), + ("sms_verification_timestamp", models.DateTimeField(blank=True, null=True)), + ("sms_verified", models.BooleanField(default=False)), + ( + "phone_number", + phonenumber_field.modelfields.PhoneNumberField( + blank=True, max_length=128, null=True, region=None + ), + ), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), + ), + ], + ), + migrations.CreateModel( + name="MembershipInvite", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("email", models.EmailField(max_length=254)), + ( + "kind", + models.CharField( + choices=[ + ("STUDENT", "Student"), + ("TA", "TA"), + ("HEAD_TA", "Head TA"), + ("PROFESSOR", "Professor"), + ], + default="STUDENT", + max_length=9, + ), + ), + ("time_created", models.DateTimeField(auto_now_add=True)), + ( + "course", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="ohq.Course"), + ), + ], + ), + migrations.CreateModel( + name="Membership", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "kind", + models.CharField( + choices=[ + ("STUDENT", "Student"), + ("TA", "TA"), + ("HEAD_TA", "Head TA"), + ("PROFESSOR", "Professor"), + ], + default="STUDENT", + max_length=9, + ), + ), + ("time_created", models.DateTimeField(auto_now_add=True)), + ("last_active", models.DateTimeField(blank=True, null=True)), + ( + "course", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="ohq.Course"), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), + ), + ], + ), + migrations.AddField( + model_name="course", + name="members", + field=models.ManyToManyField(through="ohq.Membership", to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name="course", + name="semester", + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="ohq.Semester"), + ), + migrations.AddConstraint( + model_name="queue", + constraint=models.UniqueConstraint(fields=("course", "name"), name="unique_queue_name"), + ), + migrations.AddConstraint( + model_name="membershipinvite", + constraint=models.UniqueConstraint( + fields=("course", "email"), name="unique_invited_course_user" + ), + ), + migrations.AddConstraint( + model_name="membership", + constraint=models.UniqueConstraint(fields=("course", "user"), name="unique_membership"), + ), + migrations.AddConstraint( + model_name="course", + constraint=models.UniqueConstraint( + fields=("course_code", "department", "semester"), name="unique_course_name" + ), + ), + ] diff --git a/backend/ohq/migrations/0002_auto_20200816_1727.py b/backend/ohq/migrations/0002_auto_20200816_1727.py new file mode 100644 index 00000000..08eff381 --- /dev/null +++ b/backend/ohq/migrations/0002_auto_20200816_1727.py @@ -0,0 +1,73 @@ +# Generated by Django 3.1 on 2020-08-16 21:27 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("ohq", "0001_initial"), + ] + + operations = [ + migrations.RenameField( + model_name="question", old_name="time_answered", new_name="time_responded_to", + ), + migrations.RemoveField(model_name="question", name="answered_by",), + migrations.RemoveField(model_name="question", name="rejected_by",), + migrations.RemoveField(model_name="question", name="rejected_reason_other",), + migrations.RemoveField(model_name="question", name="time_last_updated",), + migrations.RemoveField(model_name="question", name="time_rejected",), + migrations.RemoveField(model_name="question", name="time_started",), + migrations.RemoveField(model_name="question", name="time_withdrawn",), + migrations.AddField( + model_name="question", + name="responded_to_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="responded_questions", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="question", + name="status", + field=models.CharField( + choices=[ + ("ASKED", "Asked"), + ("WITHDRAWN", "Withdrawn"), + ("ACTIVE", "Active"), + ("REJECTED", "Rejected"), + ("ANSWERED", "Answered"), + ], + default="ACTIVE", + max_length=9, + ), + ), + migrations.AddField( + model_name="question", + name="time_response_started", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name="question", + name="asked_by", + field=models.ForeignKey( + default=1, + on_delete=django.db.models.deletion.CASCADE, + related_name="asked_questions", + to="auth.user", + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="question", + name="rejected_reason", + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/backend/ohq/migrations/0003_auto_20200822_1116.py b/backend/ohq/migrations/0003_auto_20200822_1116.py new file mode 100644 index 00000000..5488a19b --- /dev/null +++ b/backend/ohq/migrations/0003_auto_20200822_1116.py @@ -0,0 +1,28 @@ +# Generated by Django 3.1 on 2020-08-22 15:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("ohq", "0002_auto_20200816_1727"), + ] + + operations = [ + migrations.AlterField( + model_name="question", + name="status", + field=models.CharField( + choices=[ + ("ASKED", "Asked"), + ("WITHDRAWN", "Withdrawn"), + ("ACTIVE", "Active"), + ("REJECTED", "Rejected"), + ("ANSWERED", "Answered"), + ], + default="ASKED", + max_length=9, + ), + ), + ] diff --git a/backend/ohq/migrations/0004_auto_20200825_1344.py b/backend/ohq/migrations/0004_auto_20200825_1344.py new file mode 100644 index 00000000..9c074b70 --- /dev/null +++ b/backend/ohq/migrations/0004_auto_20200825_1344.py @@ -0,0 +1,16 @@ +# Generated by Django 3.1 on 2020-08-25 17:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("ohq", "0003_auto_20200822_1116"), + ] + + operations = [ + migrations.AlterField( + model_name="queue", name="estimated_wait_time", field=models.IntegerField(default=-1), + ), + ] diff --git a/backend/ohq/migrations/0005_auto_20201016_1702.py b/backend/ohq/migrations/0005_auto_20201016_1702.py new file mode 100644 index 00000000..4820ad32 --- /dev/null +++ b/backend/ohq/migrations/0005_auto_20201016_1702.py @@ -0,0 +1,21 @@ +# Generated by Django 3.1.1 on 2020-10-16 21:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("ohq", "0004_auto_20200825_1344"), + ] + + operations = [ + migrations.AddField( + model_name="question", + name="note", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="question", name="resolved_note", field=models.BooleanField(default=True), + ), + ] diff --git a/backend/ohq/migrations/0006_auto_20210105_2000.py b/backend/ohq/migrations/0006_auto_20210105_2000.py new file mode 100644 index 00000000..5cc0e3d4 --- /dev/null +++ b/backend/ohq/migrations/0006_auto_20210105_2000.py @@ -0,0 +1,39 @@ +# Generated by Django 3.1.1 on 2021-01-06 01:00 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("ohq", "0005_auto_20201016_1702"), + ] + + operations = [ + migrations.CreateModel( + name="Tag", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("name", models.CharField(max_length=255)), + ( + "course", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="ohq.course"), + ), + ], + ), + migrations.AddField( + model_name="question", + name="tags", + field=models.ManyToManyField(blank=True, to="ohq.Tag"), + ), + migrations.AddConstraint( + model_name="tag", + constraint=models.UniqueConstraint(fields=("name", "course"), name="unique_course_tag"), + ), + ] diff --git a/backend/ohq/migrations/0007_announcement.py b/backend/ohq/migrations/0007_announcement.py new file mode 100644 index 00000000..1d070eba --- /dev/null +++ b/backend/ohq/migrations/0007_announcement.py @@ -0,0 +1,45 @@ +# Generated by Django 3.1.1 on 2021-01-06 16:03 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("ohq", "0006_auto_20210105_2000"), + ] + + operations = [ + migrations.CreateModel( + name="Announcement", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("content", models.CharField(max_length=255)), + ("time_updated", models.DateTimeField(auto_now=True)), + ( + "author", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="announcements", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "course", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="announcements", + to="ohq.course", + ), + ), + ], + ), + ] diff --git a/backend/ohq/migrations/0008_auto_20210119_2218.py b/backend/ohq/migrations/0008_auto_20210119_2218.py new file mode 100644 index 00000000..abd48cd8 --- /dev/null +++ b/backend/ohq/migrations/0008_auto_20210119_2218.py @@ -0,0 +1,31 @@ +# Generated by Django 3.1.2 on 2021-01-20 03:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("ohq", "0007_announcement"), + ] + + operations = [ + migrations.AddField( + model_name="queue", name="rate_limit_enabled", field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="queue", + name="rate_limit_length", + field=models.IntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name="queue", + name="rate_limit_minutes", + field=models.IntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name="queue", + name="rate_limit_questions", + field=models.IntegerField(blank=True, null=True), + ), + ] diff --git a/backend/ohq/migrations/0009_auto_20210201_2224.py b/backend/ohq/migrations/0009_auto_20210201_2224.py new file mode 100644 index 00000000..a84bc833 --- /dev/null +++ b/backend/ohq/migrations/0009_auto_20210201_2224.py @@ -0,0 +1,80 @@ +# Generated by Django 3.1.2 on 2021-02-02 03:24 + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("ohq", "0008_auto_20210119_2218"), + ] + + operations = [ + migrations.CreateModel( + name="QueueStatistic", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "metric", + models.CharField( + choices=[ + ("HEATMAP_AVG_WAIT", "Average wait-time heatmap"), + ("HEATMAP_QUESTIONS_PER_TA", "Questions per TA heatmap"), + ("AVG_WAIT", "Average wait-time"), + ("NUM_ANSWERED", "Number of questions answered per week"), + ("STUDENTS_HELPED", "Students helped per week"), + ("AVG_TIME_HELPING", "Average time helping students"), + ("LIST_WAIT_TIME_DAYS", "List of wait times per day"), + ], + max_length=256, + ), + ), + ("value", models.DecimalField(decimal_places=8, max_digits=16)), + ( + "day", + models.IntegerField( + blank=True, + choices=[ + (1, "Sunday"), + (2, "Monday"), + (3, "Tuesday"), + (4, "Wednesday"), + (5, "Thursday"), + (6, "Friday"), + (7, "Saturday"), + ], + null=True, + ), + ), + ( + "hour", + models.IntegerField( + blank=True, + null=True, + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MaxValueValidator(23), + ], + ), + ), + ("date", models.DateField(blank=True, null=True)), + ( + "queue", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="ohq.queue"), + ), + ], + ), + migrations.AddConstraint( + model_name="queuestatistic", + constraint=models.UniqueConstraint( + fields=("queue", "metric", "day", "hour", "date"), name="unique_statistic" + ), + ), + ] diff --git a/backend/ohq/migrations/0010_auto_20210405_1720.py b/backend/ohq/migrations/0010_auto_20210405_1720.py new file mode 100644 index 00000000..09110da0 --- /dev/null +++ b/backend/ohq/migrations/0010_auto_20210405_1720.py @@ -0,0 +1,28 @@ +# Generated by Django 3.1.7 on 2021-04-05 21:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("ohq", "0009_auto_20210201_2224"), + ] + + operations = [ + migrations.AlterField( + model_name="queuestatistic", + name="metric", + field=models.CharField( + choices=[ + ("HEATMAP_AVG_WAIT", "Average wait-time heatmap"), + ("HEATMAP_QUESTIONS_PER_TA", "Questions per TA heatmap"), + ("AVG_WAIT", "Average wait-time per day"), + ("NUM_ANSWERED", "Number of questions answered per day"), + ("STUDENTS_HELPED", "Students helped per day"), + ("AVG_TIME_HELPING", "Average time helping students"), + ], + max_length=256, + ), + ), + ] diff --git a/backend/ohq/migrations/0010_auto_20210407_0145.py b/backend/ohq/migrations/0010_auto_20210407_0145.py new file mode 100644 index 00000000..7669354f --- /dev/null +++ b/backend/ohq/migrations/0010_auto_20210407_0145.py @@ -0,0 +1,14 @@ +# Generated by Django 3.1.7 on 2021-04-07 01:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("ohq", "0009_auto_20210201_2224"), + ] + + operations = [ + migrations.AlterField(model_name="announcement", name="content", field=models.TextField(),), + ] diff --git a/backend/ohq/migrations/0011_merge_20210415_2110.py b/backend/ohq/migrations/0011_merge_20210415_2110.py new file mode 100644 index 00000000..4a1a151c --- /dev/null +++ b/backend/ohq/migrations/0011_merge_20210415_2110.py @@ -0,0 +1,13 @@ +# Generated by Django 3.1.7 on 2021-04-15 21:10 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("ohq", "0010_auto_20210407_0145"), + ("ohq", "0010_auto_20210405_1720"), + ] + + operations = [] diff --git a/backend/ohq/migrations/0012_queue_require_video_chat_url_on_questions.py b/backend/ohq/migrations/0012_queue_require_video_chat_url_on_questions.py new file mode 100644 index 00000000..b22b84eb --- /dev/null +++ b/backend/ohq/migrations/0012_queue_require_video_chat_url_on_questions.py @@ -0,0 +1,43 @@ +# Generated by Django 3.1.7 on 2021-09-17 16:29 + +from django.db import migrations, models + + +def get_video_chat_setting(queue): + if queue.course.require_video_chat_url_on_questions: + return "REQUIRED" + elif queue.course.video_chat_enabled: + return "OPTIONAL" + else: + return "DISABLED" + + +def populate_require_url_in_queue(apps, schema_editor): + Queue = apps.get_model("ohq", "Queue") + for queue in Queue.objects.all(): + queue.video_chat_setting = get_video_chat_setting(queue) + queue.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("ohq", "0011_merge_20210415_2110"), + ] + + operations = [ + migrations.AddField( + model_name="queue", + name="video_chat_setting", + field=models.CharField( + choices=[ + ("REQUIRED", "required"), + ("OPTIONAL", "optional"), + ("DISABLED", "disabled"), + ], + default="OPTIONAL", + max_length=8, + ), + ), + migrations.RunPython(populate_require_url_in_queue), + ] diff --git a/backend/ohq/migrations/0013_auto_20210924_2056.py b/backend/ohq/migrations/0013_auto_20210924_2056.py new file mode 100644 index 00000000..cd3f5045 --- /dev/null +++ b/backend/ohq/migrations/0013_auto_20210924_2056.py @@ -0,0 +1,15 @@ +# Generated by Django 3.1.7 on 2021-09-24 20:56 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("ohq", "0012_queue_require_video_chat_url_on_questions"), + ] + + operations = [ + migrations.RemoveField(model_name="course", name="require_video_chat_url_on_questions",), + migrations.RemoveField(model_name="course", name="video_chat_enabled",), + ] diff --git a/backend/ohq/migrations/0014_question_student_descriptor.py b/backend/ohq/migrations/0014_question_student_descriptor.py new file mode 100644 index 00000000..fa50c2d8 --- /dev/null +++ b/backend/ohq/migrations/0014_question_student_descriptor.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.7 on 2021-10-03 17:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("ohq", "0013_auto_20210924_2056"), + ] + + operations = [ + migrations.AddField( + model_name="question", + name="student_descriptor", + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/backend/ohq/migrations/0015_question_templates.py b/backend/ohq/migrations/0015_question_templates.py new file mode 100644 index 00000000..cb2bd5e0 --- /dev/null +++ b/backend/ohq/migrations/0015_question_templates.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("ohq", "0014_question_student_descriptor"), + ] + + operations = [ + migrations.AddField( + model_name="queue", + name="question_template", + field=models.TextField(blank=True, default=""), + ), + ] diff --git a/backend/ohq/migrations/0016_auto_20211008_2136.py b/backend/ohq/migrations/0016_auto_20211008_2136.py new file mode 100644 index 00000000..dbd187d4 --- /dev/null +++ b/backend/ohq/migrations/0016_auto_20211008_2136.py @@ -0,0 +1,21 @@ +# Generated by Django 3.1.7 on 2021-10-08 21:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("ohq", "0015_question_templates"), + ] + + operations = [ + migrations.AddField( + model_name="queue", name="pin_enabled", field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="queue", + name="pin", + field=models.CharField(blank=True, default="", max_length=5, null=True), + ), + ] diff --git a/backend/ohq/migrations/0017_auto_20211031_1615.py b/backend/ohq/migrations/0017_auto_20211031_1615.py new file mode 100644 index 00000000..e554c57d --- /dev/null +++ b/backend/ohq/migrations/0017_auto_20211031_1615.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.7 on 2021-10-31 16:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("ohq", "0016_auto_20211008_2136"), + ] + + operations = [ + migrations.AlterField( + model_name="queue", + name="pin", + field=models.CharField(blank=True, max_length=50, null=True), + ), + ] diff --git a/backend/ohq/migrations/0018_auto_20220125_0344.py b/backend/ohq/migrations/0018_auto_20220125_0344.py new file mode 100644 index 00000000..374deeaf --- /dev/null +++ b/backend/ohq/migrations/0018_auto_20220125_0344.py @@ -0,0 +1,16 @@ +# Generated by Django 3.1.7 on 2022-01-25 03:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("ohq", "0017_auto_20211031_1615"), + ] + + operations = [ + migrations.AlterField( + model_name="course", name="course_title", field=models.CharField(max_length=100), + ), + ] diff --git a/backend/ohq/migrations/0019_auto_20211114_1800.py b/backend/ohq/migrations/0019_auto_20211114_1800.py new file mode 100644 index 00000000..98176271 --- /dev/null +++ b/backend/ohq/migrations/0019_auto_20211114_1800.py @@ -0,0 +1,57 @@ +# Generated by Django 3.1.7 on 2021-11-14 18:00 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("ohq", "0018_auto_20220125_0344"), + ] + + operations = [ + migrations.CreateModel( + name="CourseStatistic", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "metric", + models.CharField( + choices=[ + ("STUDENT_QUESTIONS_ASKED", "Student: Questions asked"), + ("STUDENT_TIME_BEING_HELPED", "Student: Time being helped"), + ("INSTR_QUESTIONS_ANSWERED", "Instructor: Questions answered"), + ("INSTR_TIME_ANSWERING", "Instructor: Time answering questions"), + ], + max_length=256, + ), + ), + ("value", models.DecimalField(decimal_places=8, max_digits=16)), + ("date", models.DateField(blank=True, null=True)), + ( + "course", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="ohq.course"), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), + ), + ], + ), + migrations.AddConstraint( + model_name="coursestatistic", + constraint=models.UniqueConstraint( + fields=("user", "course", "metric", "date"), name="course_statistic" + ), + ), + ] diff --git a/backend/ohq/migrations/__init__.py b/backend/ohq/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/ohq/models.py b/backend/ohq/models.py new file mode 100644 index 00000000..a3284c0a --- /dev/null +++ b/backend/ohq/models.py @@ -0,0 +1,413 @@ +from django.conf import settings +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.dispatch import receiver +from email_tools.emails import send_email +from phonenumber_field.modelfields import PhoneNumberField + + +User = settings.AUTH_USER_MODEL + + +class Profile(models.Model): + """ + An extension to a User object that includes additional information. + """ + + # preferred_name = models.CharField(max_length=100) + user = models.OneToOneField(User, on_delete=models.CASCADE) + + sms_notifications_enabled = models.BooleanField(default=False) + sms_verification_code = models.CharField(max_length=6, blank=True, null=True) + sms_verification_timestamp = models.DateTimeField(blank=True, null=True) + sms_verified = models.BooleanField(default=False) + phone_number = PhoneNumberField(blank=True, null=True) + + SMS_VERIFICATION_EXPIRATION_MINUTES = 10 + + def __str__(self): + return str(self.user) + + +@receiver(models.signals.post_save, sender=User) +def create_or_update_user_profile(sender, instance, created, **kwargs): + Profile.objects.get_or_create(user=instance) + instance.profile.save() + + +class Semester(models.Model): + """ + A semester used to indicate when questions were used. + Contains a year and season. + """ + + TERM_SPRING = "SPRING" + TERM_SUMMER = "SUMMER" + TERM_FALL = "FALL" + TERM_WINTER = "WINTER" + TERM_CHOICES = [ + (TERM_SPRING, "Spring"), + (TERM_SUMMER, "Summer"), + (TERM_FALL, "Fall"), + (TERM_WINTER, "Winter"), + ] + year = models.IntegerField() + term = models.CharField(max_length=6, choices=TERM_CHOICES, default=TERM_FALL) + + def term_to_pretty(self): + return self.term.title() + + def __str__(self): + return f"{self.term_to_pretty()} {self.year}" + + +class Course(models.Model): + """ + A course taught in a specific semester. + """ + + course_code = models.CharField(max_length=10) + department = models.CharField(max_length=10) + course_title = models.CharField(max_length=100) + description = models.CharField(max_length=255, blank=True) + semester = models.ForeignKey(Semester, on_delete=models.CASCADE) + archived = models.BooleanField(default=False) + invite_only = models.BooleanField(default=False) + members = models.ManyToManyField(User, through="Membership", through_fields=("course", "user")) + + # MAX_NUMBER_COURSE_USERS = 1000 + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["course_code", "department", "semester"], name="unique_course_name" + ) + ] + + def __str__(self): + return f"{self.department} {self.course_code}: {str(self.semester)}" + + +class Membership(models.Model): + """ + Represents a relationship between a user and a course. + """ + + KIND_STUDENT = "STUDENT" + KIND_TA = "TA" + KIND_HEAD_TA = "HEAD_TA" + KIND_PROFESSOR = "PROFESSOR" + KIND_CHOICES = [ + (KIND_STUDENT, "Student"), + (KIND_TA, "TA"), + (KIND_HEAD_TA, "Head TA"), + (KIND_PROFESSOR, "Professor"), + ] + course = models.ForeignKey(Course, on_delete=models.CASCADE) + user = models.ForeignKey(User, on_delete=models.CASCADE) + kind = models.CharField(max_length=9, choices=KIND_CHOICES, default=KIND_STUDENT) + time_created = models.DateTimeField(auto_now_add=True) + + # For staff + last_active = models.DateTimeField(blank=True, null=True) + + class Meta: + constraints = [models.UniqueConstraint(fields=["course", "user"], name="unique_membership")] + + @property + def is_leadership(self): + return self.kind in [Membership.KIND_PROFESSOR, Membership.KIND_HEAD_TA] + + @property + def is_ta(self): + return self.is_leadership or self.kind == Membership.KIND_TA + + def kind_to_pretty(self): + return [pretty for raw, pretty in self.KIND_CHOICES if raw == self.kind][0] + + def send_email(self): + """ + Send the email associated with this invitation to the user. + """ + + context = { + "course": f"{self.course.department} {self.course.course_code}", + "role": self.kind_to_pretty(), + "product_link": f"https://{settings.DOMAINS[0]}", + } + subject = f"You've been added to {context['course']} OHQ" + send_email("emails/course_added.html", context, subject, self.user.email) + + def __str__(self): + return f"" + + +class MembershipInvite(models.Model): + """ + Represents an invitation to a course. + """ + + course = models.ForeignKey(Course, on_delete=models.CASCADE) + email = models.EmailField() + kind = models.CharField( + max_length=9, choices=Membership.KIND_CHOICES, default=Membership.KIND_STUDENT + ) + time_created = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=["course", "email"], name="unique_invited_course_user") + ] + + def kind_to_pretty(self): + return [pretty for raw, pretty in Membership.KIND_CHOICES if raw == self.kind][0] + + def send_email(self): + """ + Send the email associated with this invitation to the user. + """ + + context = { + "course": f"{self.course.department} {self.course.course_code}", + "role": self.kind_to_pretty(), + "product_link": f"https://{settings.DOMAINS[0]}", + } + subject = f"Invitation to join {context['course']} OHQ" + send_email("emails/course_invitation.html", context, subject, self.email) + + def __str__(self): + return f"" + + +class Queue(models.Model): + """ + A single office hours queue for a class. + """ + + VIDEO_REQUIRED = "REQUIRED" + VIDEO_OPTIONAL = "OPTIONAL" + VIDEO_DISABLED = "DISABLED" + VIDEO_CHOICES = [ + (VIDEO_REQUIRED, "required"), + (VIDEO_OPTIONAL, "optional"), + (VIDEO_DISABLED, "disabled"), + ] + + name = models.CharField(max_length=255) + description = models.TextField() + question_template = models.TextField(blank=True, default="") + course = models.ForeignKey(Course, on_delete=models.CASCADE) + archived = models.BooleanField(default=False) + pin_enabled = models.BooleanField(default=False) + pin = models.CharField(max_length=50, blank=True, null=True) + + # Estimated wait time for the queue, in minutes + estimated_wait_time = models.IntegerField(default=-1) + active = models.BooleanField(default=False) + # TODO: re-add some sort of scheduling feature? + + # MAX_NUMBER_QUEUES = 2 + + # particular user can ask rate_limit_questions in rate_limit_minutes if the queue length is + # greater than rate_limit_length + rate_limit_enabled = models.BooleanField(default=False) + rate_limit_length = models.IntegerField(blank=True, null=True) + rate_limit_questions = models.IntegerField(blank=True, null=True) + rate_limit_minutes = models.IntegerField(blank=True, null=True) + + video_chat_setting = models.CharField( + max_length=8, choices=VIDEO_CHOICES, default=VIDEO_OPTIONAL + ) + + class Meta: + constraints = [models.UniqueConstraint(fields=["course", "name"], name="unique_queue_name")] + + def __str__(self): + return f"{self.course}: {self.name}" + + +class Tag(models.Model): + """ + Tags for a course. + """ + + name = models.CharField(max_length=255) + course = models.ForeignKey(Course, on_delete=models.CASCADE) + + class Meta: + constraints = [models.UniqueConstraint(fields=["name", "course"], name="unique_course_tag")] + + def __str__(self): + return f"{self.course}: {self.name}" + + +class Question(models.Model): + """ + A question asked within a queue. + """ + + STATUS_ASKED = "ASKED" + STATUS_WITHDRAWN = "WITHDRAWN" + STATUS_ACTIVE = "ACTIVE" + STATUS_REJECTED = "REJECTED" + STATUS_ANSWERED = "ANSWERED" + STATUS_CHOICES = [ + (STATUS_ASKED, "Asked"), + (STATUS_WITHDRAWN, "Withdrawn"), + (STATUS_ACTIVE, "Active"), + (STATUS_REJECTED, "Rejected"), + (STATUS_ANSWERED, "Answered"), + ] + text = models.TextField() + queue = models.ForeignKey(Queue, on_delete=models.CASCADE) + video_chat_url = models.URLField(blank=True, null=True) + + note = models.CharField(max_length=255, blank=True, null=True) + resolved_note = models.BooleanField(default=True) + + status = models.CharField(max_length=9, choices=STATUS_CHOICES, default=STATUS_ASKED) + + time_asked = models.DateTimeField(auto_now_add=True) + asked_by = models.ForeignKey(User, related_name="asked_questions", on_delete=models.CASCADE) + + time_response_started = models.DateTimeField(blank=True, null=True) + time_responded_to = models.DateTimeField(blank=True, null=True) + responded_to_by = models.ForeignKey( + User, related_name="responded_questions", on_delete=models.SET_NULL, blank=True, null=True + ) + # This field should be a custom message or one of the following: + # OTHER, NOT_HERE, OH_ENDED, NOT_SPECIFIC, MISSING_TEMPLATE, or WRONG_QUEUE + rejected_reason = models.CharField(max_length=255, blank=True, null=True) + + should_send_up_soon_notification = models.BooleanField(default=False) + tags = models.ManyToManyField(Tag, blank=True) + student_descriptor = models.CharField(max_length=255, blank=True, null=True) + + +class CourseStatistic(models.Model): + """ + Most active students/TAs in the past week for a course + """ + + METRIC_STUDENT_QUESTIONS_ASKED = "STUDENT_QUESTIONS_ASKED" + METRIC_STUDENT_TIME_BEING_HELPED = "STUDENT_TIME_BEING_HELPED" + METRIC_INSTR_QUESTIONS_ANSWERED = "INSTR_QUESTIONS_ANSWERED" + METRIC_INSTR_TIME_ANSWERING = "INSTR_TIME_ANSWERING" + + METRIC_CHOICES = [ + (METRIC_STUDENT_QUESTIONS_ASKED, "Student: Questions asked"), + (METRIC_STUDENT_TIME_BEING_HELPED, "Student: Time being helped"), + (METRIC_INSTR_QUESTIONS_ANSWERED, "Instructor: Questions answered"), + (METRIC_INSTR_TIME_ANSWERING, "Instructor: Time answering questions"), + ] + + user = models.ForeignKey(User, on_delete=models.CASCADE) + course = models.ForeignKey(Course, on_delete=models.CASCADE) + metric = models.CharField(max_length=256, choices=METRIC_CHOICES) + value = models.DecimalField(max_digits=16, decimal_places=8) + date = models.DateField(blank=True, null=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user", "course", "metric", "date"], name="course_statistic" + ) + ] + + def metric_to_pretty(self): + return [pretty for raw, pretty in CourseStatistic.METRIC_CHOICES if raw == self.metric][0] + + def __str__(self): + return f"{self.course}: {self.date}: {self.metric_to_pretty()}" + + +class QueueStatistic(models.Model): + """ + Statistics related to a queue + """ + + # add new metrics/statistics + METRIC_HEATMAP_WAIT = "HEATMAP_AVG_WAIT" + METRIC_HEATMAP_QUESTIONS_PER_TA = "HEATMAP_QUESTIONS_PER_TA" + METRIC_AVG_WAIT = "AVG_WAIT" + METRIC_NUM_ANSWERED = "NUM_ANSWERED" + METRIC_STUDENTS_HELPED = "STUDENTS_HELPED" + METRIC_AVG_TIME_HELPING = "AVG_TIME_HELPING" + METRIC_CHOICES = [ + (METRIC_HEATMAP_WAIT, "Average wait-time heatmap"), + (METRIC_HEATMAP_QUESTIONS_PER_TA, "Questions per TA heatmap"), + (METRIC_AVG_WAIT, "Average wait-time per day"), + (METRIC_NUM_ANSWERED, "Number of questions answered per day"), + (METRIC_STUDENTS_HELPED, "Students helped per day"), + (METRIC_AVG_TIME_HELPING, "Average time helping students"), + ] + + # for specific days during the week - used for heatmap and graphs where day is x-axis + DAY_SUNDAY = 1 + DAY_MONDAY = 2 + DAY_TUESDAY = 3 + DAY_WEDNESDAY = 4 + DAY_THURSDAY = 5 + DAY_FRIDAY = 6 + DAY_SATURDAY = 7 + DAY_CHOICES = [ + (DAY_SUNDAY, "Sunday"), + (DAY_MONDAY, "Monday"), + (DAY_TUESDAY, "Tuesday"), + (DAY_WEDNESDAY, "Wednesday"), + (DAY_THURSDAY, "Thursday"), + (DAY_FRIDAY, "Friday"), + (DAY_SATURDAY, "Saturday"), + ] + + queue = models.ForeignKey(Queue, on_delete=models.CASCADE) + metric = models.CharField(max_length=256, choices=METRIC_CHOICES) + value = models.DecimalField(max_digits=16, decimal_places=8) + + day = models.IntegerField(choices=DAY_CHOICES, blank=True, null=True) + hour = models.IntegerField( + validators=[MinValueValidator(0), MaxValueValidator(23)], blank=True, null=True + ) + date = models.DateField(blank=True, null=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["queue", "metric", "day", "hour", "date"], name="unique_statistic" + ) + ] + + def metric_to_pretty(self): + return [pretty for raw, pretty in QueueStatistic.METRIC_CHOICES if raw == self.metric][0] + + def day_to_pretty(self): + pretty_lst = [pretty for raw, pretty in QueueStatistic.DAY_CHOICES if raw == self.day] + if len(pretty_lst): + return pretty_lst[0] + else: + return "" + + def hour_to_pretty(self): + if self.hour is not None: + return f"{self.hour}:00 - {self.hour + 1}:00" + else: + return "" + + def __str__(self): + string = f"{self.queue}: {self.metric_to_pretty()}" + if self.day_to_pretty() != "": + string += " " + self.day_to_pretty() + if self.hour_to_pretty() != "": + string += " " + self.hour_to_pretty() + return string + + +class Announcement(models.Model): + """ + TA announcement within a class + """ + + content = models.TextField() + author = models.ForeignKey(User, related_name="announcements", on_delete=models.CASCADE) + time_updated = models.DateTimeField(auto_now=True) + course = models.ForeignKey(Course, related_name="announcements", on_delete=models.CASCADE) diff --git a/backend/ohq/pagination.py b/backend/ohq/pagination.py new file mode 100644 index 00000000..912c9bec --- /dev/null +++ b/backend/ohq/pagination.py @@ -0,0 +1,9 @@ +from rest_framework.pagination import PageNumberPagination + + +class QuestionSearchPagination(PageNumberPagination): + """ + Custom pagination for QuestionListView. + """ + + page_size = 20 diff --git a/backend/ohq/permissions.py b/backend/ohq/permissions.py new file mode 100644 index 00000000..eb026aba --- /dev/null +++ b/backend/ohq/permissions.py @@ -0,0 +1,505 @@ +from django.db.models import Q +from rest_framework import permissions +from schedule.models import Event, EventRelation, Occurrence + +from ohq.models import Course, Membership, Question + + +# Hierarchy of permissions is usually: +# Professor > Head TA > TA > Student > User +# Anonymous Users can't do anything +# Note: everything other than IsSuperuser and CoursePermission is +# scoped to a specific course. + + +class IsSuperuser(permissions.BasePermission): + """ + Grants permission if the current user is a superuser. + """ + + def has_object_permission(self, request, view, obj): + return request.user.is_authenticated and request.user.is_superuser + + def has_permission(self, request, view): + return request.user.is_authenticated and request.user.is_superuser + + +class CoursePermission(permissions.BasePermission): + """ + Anyone can get or list courses. + Only head TAs or professors should be able to modify a course. + Current Head TAs+ or faculty members can create courses. + No one can delete courses. + """ + + def has_object_permission(self, request, view, obj): + # Anyone can get a single course + if view.action == "retrieve": + return True + + membership = Membership.objects.filter(course=obj, user=request.user).first() + + # Non members can't do anything other than retrieve a course + if membership is None: + return False + + # No one can delete a course + if view.action == "destroy": + return False + + # Course leadership can make changes + if view.action in ["update", "partial_update"]: + return membership.is_leadership and not obj.archived + + def has_permission(self, request, view): + # Anonymous users can't do anything + if not request.user.is_authenticated: + return False + + # Members can list courses + if view.action == "list": + return True + + if view.action == "create": + return ( + request.user.groups.filter(name="platform_faculty").exists() + or Membership.objects.filter( + user=request.user, kind=Membership.KIND_HEAD_TA + ).exists() + or Membership.objects.filter( + user=request.user, kind=Membership.KIND_PROFESSOR + ).exists() + ) + + return True + + +class QueuePermission(permissions.BasePermission): + """ + TAs+ should be able to modify a course. + Only head TAs or professors can create or delete queues. + Students+ can get or list queues + """ + + def has_object_permission(self, request, view, obj): + membership = Membership.objects.get(course=view.kwargs["course_pk"], user=request.user) + + # Students+ can get a single queue + if view.action == "retrieve": + return True + + # TAs+ can make changes + if view.action in ["update", "partial_update", "clear"]: + return membership.is_ta and not obj.archived + + # Head TAs+ can create or delete a queue + if view.action == "destroy": + return membership.is_leadership + + def has_permission(self, request, view): + # Anonymous users can't do anything + if not request.user.is_authenticated: + return False + + membership = Membership.objects.filter( + course=view.kwargs["course_pk"], user=request.user + ).first() + + # Non-Students can't do anything + if membership is None: + return False + + # TAs+ can make changes + if view.action in ["update", "partial_update"]: + return membership.is_ta + + # Head TAs+ can create or delete a queue + if view.action == "create": + return membership.is_leadership + + return True + + +class QuestionPermission(permissions.BasePermission): + """ + Students can create questions + Students can get, list, or modify their own questions. + TAs+ can list questions and modify any question. + No one can delete questions. + """ + + def has_object_permission(self, request, view, obj): + membership = Membership.objects.get(course=view.kwargs["course_pk"], user=request.user) + + # Students can get or modify their own question + # TAs+ can get or modify any questions + if view.action in ["retrieve", "update", "partial_update", "position"]: + return obj.asked_by == request.user or membership.is_ta + + def has_permission(self, request, view): + # Anonymous users can't do anything + if not request.user.is_authenticated: + return False + + membership = Membership.objects.filter( + course=view.kwargs["course_pk"], user=request.user + ).first() + + # Non-Students can't do anything + if membership is None: + return False + + # No one can delete questions + if view.action == "destroy": + return False + + # Students can view their last asked question: + if view.action in ["last", "quota_count"]: + return membership.kind == Membership.KIND_STUDENT + + # Students can only create 1 question per queue + if view.action == "create": + existing_question = Question.objects.filter( + Q(queue=view.kwargs["queue_pk"]) + & Q(asked_by=request.user) + & (Q(status=Question.STATUS_ASKED) | Q(status=Question.STATUS_ACTIVE)) + ).first() + + return membership.kind == Membership.KIND_STUDENT and existing_question is None + + # Students+ can get, list, or modify questions + # With restrictions defined in has_object_permission + return True + + +class QuestionSearchPermission(permissions.BasePermission): + """ + TAs+ can list questions. + """ + + def has_permission(self, request, view): + # Anonymous users can't do anything + if not request.user.is_authenticated: + return False + + membership = Membership.objects.filter( + course=view.kwargs["course_pk"], user=request.user + ).first() + + # Non-Students can't do anything + if membership is None: + return False + + # TAs+ can list questions + return membership.is_ta + + +class MembershipPermission(permissions.BasePermission): + """ + Students can get their own membership. + TAs+ can list memberships. + Users can create a Student membership with open courses. + Head TAs+ can modify and delete memberships. + Students can delete their own memberships. + """ + + def has_object_permission(self, request, view, obj): + membership = Membership.objects.get(course=view.kwargs["course_pk"], user=request.user) + + # Students can get their own memberships + # TAs+ can get any memberships + if view.action == "retrieve": + return obj.user == request.user or membership.is_ta + + # Students can delete their own memberships + # Head TAs+ can delete any memberships + # TODO: make sure Head TAs+ can't delete professors + # and professors can't delete themselves + if view.action == "destroy": + return obj.user == request.user or membership.is_leadership + + # Head TAs+ can modify any membership + # TODO: make sure Head TAs+ can't delete professors + # and professors can't delete themselves + if view.action in ["update", "partial_update"]: + return membership.is_leadership + + def has_permission(self, request, view): + # Anonymous users can't do anything + if not request.user.is_authenticated: + return False + + membership = Membership.objects.filter( + course=view.kwargs["course_pk"], user=request.user + ).first() + + # No one can create a membership + if view.action == "create": + course = Course.objects.get(id=view.kwargs["course_pk"]) + return membership is None and not course.invite_only + + # Non-Students can't do anything besides create a membership + if membership is None: + return False + + # Students+ can get, modify, and delete memberships + # can list memberships of leaders. + # With restrictions defined in has_object_permission + return True + + +class MembershipInvitePermission(permissions.BasePermission): + """ + TAs+ can get and list membership invites. + Head TAs+ can create, modify, and delete memberships. + """ + + def has_object_permission(self, request, view, obj): + membership = Membership.objects.get(course=view.kwargs["course_pk"], user=request.user) + + # TAs+ can get any membership invite + if view.action == "retrieve": + return membership.is_ta + + # Head TAs+ can modify or delete any memberships + if view.action in ["destroy", "update", "partial_update"]: + return membership.is_leadership + + def has_permission(self, request, view): + # Anonymous users can't do anything + if not request.user.is_authenticated: + return False + + membership = Membership.objects.filter( + course=view.kwargs["course_pk"], user=request.user + ).first() + + # Non-Students can't do anything + if membership is None: + return False + + # Head TAs+ can create membership invites + if view.action == "create": + return membership.is_leadership + + # TAs+ can list membership invites + if view.action == "list": + return membership.is_ta + + # TAs+ can get, modify, and delete memberships + # With restrictions defined in has_object_permission + return membership.is_ta + + +class MassInvitePermission(permissions.BasePermission): + """ + Head TAs+ can create mass membership invites. + """ + + def has_permission(self, request, view): + # Anonymous users can't do anything + if not request.user.is_authenticated: + return False + + membership = Membership.objects.filter( + course=view.kwargs["course_pk"], user=request.user + ).first() + + # Non-Students can't do anything + if membership is None: + return False + + return membership.is_leadership + + +class CourseStatisticPermission(permissions.BasePermission): + """ + TA+ can access course related statistics + """ + + def has_permission(self, request, view): + # Anonymous users can't do anything + if not request.user.is_authenticated: + return False + + membership = ( + Membership.objects.filter(course=view.kwargs["course_pk"], user=request.user) + .exclude(kind=Membership.KIND_STUDENT) + .first() + ) + + # anyone who is an instructor of the class can see course related statistics + return membership is not None + + +class QueueStatisticPermission(permissions.BasePermission): + """ + Students+ can access queue related statistics + """ + + def has_permission(self, request, view): + # Anonymous users can't do anything + if not request.user.is_authenticated: + return False + + membership = Membership.objects.filter( + course=view.kwargs["course_pk"], user=request.user + ).first() + + # anyone who is a member of the class can see queue related statistics + return membership is not None + + +class AnnouncementPermission(permissions.BasePermission): + """ + TAs+ can create/update/delete announcements + Students can get/list announcements + """ + + def has_permission(self, request, view): + # Anonymous users can't do anything + if not request.user.is_authenticated: + return False + + membership = Membership.objects.filter( + course=view.kwargs["course_pk"], user=request.user + ).first() + + # Non-Students can't do anything + if membership is None: + return False + + # Students+ can get/list announcements + if view.action in ["list", "retrieve"]: + return True + + # TAs+ can create, modify, and delete announcements + return membership.is_ta + + +class TagPermission(permissions.BasePermission): + """ + Head TAs+ should be able to create, modify and delete a tag. + Students+ can get or list tags. + """ + + def has_object_permission(self, request, view, obj): + membership = Membership.objects.get(course=view.kwargs["course_pk"], user=request.user) + + # Students+ can get a single tag + if view.action == "retrieve": + return True + + # Head TAs+ can make changes + if view.action in ["destroy", "partial_update", "update"]: + return membership.is_leadership + + def has_permission(self, request, view): + # Anonymous users can't do anything + if not request.user.is_authenticated: + return False + + membership = Membership.objects.filter( + course=view.kwargs["course_pk"], user=request.user + ).first() + + # Non-Students can't do anything + if membership is None: + return False + + if view.action in ["list", "retrieve"]: + return True + + # Head TAs+ can make changes + if view.action in ["create", "destroy", "update", "partial_update"]: + return membership.is_leadership + + +class EventPermission(permissions.BasePermission): + def get_membership_from_event(self, request, event): + event_course_relation = EventRelation.objects.filter(event=event).first() + membership = Membership.objects.filter( + course_id=event_course_relation.object_id, user=request.user + ).first() + return membership + + def has_object_permission(self, request, view, obj): + if view.action in ["partial_update", "update"]: + event = Event.objects.filter(pk=view.kwargs["pk"]).first() + membership = self.get_membership_from_event(request, event) + if membership is None: + return False + return membership.is_ta + + if view.action in ["retrieve", "destroy"]: + event = Event.objects.filter(pk=view.kwargs["pk"]).first() + membership = self.get_membership_from_event(request, event) + if membership is None: + return False + + return (view.action == "retrieve") or (view.action == "destroy" and membership.is_ta) + + return False + + def has_permission(self, request, view): + if not request.user.is_authenticated: + return False + + # Anonymous users can't do anything + if view.action in ["create"]: + course_pk = request.data.get("course_id", None) + if course_pk is None: + return False + + course = Course.objects.get(pk=course_pk) + membership = Membership.objects.filter(course=course, user=request.user).first() + if membership is None: + return False + return membership.is_ta + + if view.action in ["list"]: + course_ids = request.GET.getlist("course") + for course in course_ids: + membership = Membership.objects.filter(course=course, user=request.user).first() + if membership is None: + return False + return True + + return True + + +class OccurrencePermission(permissions.BasePermission): + def get_membership_from_event(self, request, event): + event_course_relation = EventRelation.objects.filter(event=event).first() + membership = Membership.objects.filter( + course_id=event_course_relation.object_id, user=request.user + ).first() + return membership + + def has_object_permission(self, request, view, obj): + if view.action in ["retrieve"]: + occurrence = Occurrence.objects.filter(pk=view.kwargs["pk"]).first() + membership = self.get_membership_from_event(request=request, event=occurrence.event) + return membership is not None + + if view.action in ["update", "partial_update"]: + occurrence = Occurrence.objects.filter(pk=view.kwargs["pk"]).first() + membership = self.get_membership_from_event(request, occurrence.event) + return membership is not None and membership.is_ta + + return False + + def has_permission(self, request, view): + if not request.user.is_authenticated: + return False + + if view.action in ["list"]: + # if any member of the course in the list is not accessible, return false + course_ids = request.GET.getlist("course") + for course in course_ids: + membership = Membership.objects.filter(course=course, user=request.user).first() + if membership is None: + return False + return True + + return True diff --git a/backend/ohq/queues.py b/backend/ohq/queues.py new file mode 100644 index 00000000..d8e08b63 --- /dev/null +++ b/backend/ohq/queues.py @@ -0,0 +1,27 @@ +from datetime import timedelta + +from django.db.models import Avg, F +from django.utils import timezone + +from ohq.models import Question, Queue + + +def calculate_wait_times(): + """ + Generate the average wait time for a queue by averaging the time it took to respond to all + questions in the last 10 minutes. Set the wait time to -1 for all closed queues with no + remaining questions. + """ + + # TODO: don't set wait time to -1 if a queue still has questions in it + Queue.objects.filter(archived=False, active=False).update(estimated_wait_time=-1) + + time = timezone.now() - timedelta(minutes=10) + queues = Queue.objects.filter(archived=False, active=True) + for queue in queues: + avg = Question.objects.filter(queue=queue, time_response_started__gt=time).aggregate( + avg_wait=Avg(F("time_response_started") - F("time_asked")) + ) + wait = avg["avg_wait"] + queue.estimated_wait_time = wait.seconds // 60 if wait else 0 + queue.save() diff --git a/backend/ohq/routing.py b/backend/ohq/routing.py new file mode 100644 index 00000000..6072eaa9 --- /dev/null +++ b/backend/ohq/routing.py @@ -0,0 +1,8 @@ +from django.urls import path + +from ohq.urls import realtime_router + + +websocket_urlpatterns = [ + path("api/ws/subscribe/", realtime_router.as_consumer(), name="subscriptions"), +] diff --git a/backend/ohq/schemas.py b/backend/ohq/schemas.py new file mode 100644 index 00000000..63a282e0 --- /dev/null +++ b/backend/ohq/schemas.py @@ -0,0 +1,94 @@ +from rest_framework.schemas.openapi import AutoSchema + + +class MassInviteSchema(AutoSchema): + def get_operation(self, path, method): + operation = super().get_operation("/", method) + operation["requestBody"] = { + "content": { + "application/json": { + "schema": { + "properties": { + "emails": {"type": "string"}, + "kind": {"enum": ["STUDENT", "TA", "HEAD_TA", "PROFESSOR"]}, + } + } + } + } + } + operation["parameters"] = [ + {"name": "course_pk", "required": True, "in": "path", "schema": {"type": "string"}} + ] + operation["responses"] = { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "detail": {"type": "string"}, + "members_added": {"type": "integer"}, + "invites_sent": {"type": "integer"}, + }, + } + } + } + } + } + return operation + + +class EventSchema(AutoSchema): + def get_operation(self, path, method): + op = super().get_operation(path, method) + if op["operationId"] == "listEvents": + op["parameters"].append( + { + "name": "course", + "in": "query", + "required": True, + "description": "A series of api/events/?course=1&course=2 " + + "- where the numbers are the course pks", + "schema": {"type": "string"}, + } + ) + return op + + +class OccurrenceSchema(AutoSchema): + def get_operation(self, path, method): + op = super().get_operation(path, method) + if op["operationId"] == "listOccurrences": + op["parameters"].append( + { + "name": "course", + "in": "query", + "required": True, + "description": "A series of api/occurrences/?course=1&course=2 " + + "- where the numbers are the course pks", + "schema": {"type": "string"}, + } + ) + op["parameters"].append( + { + "name": "filter_start", + "in": "query", + "required": True, + "description": "The start date of the filter in ISO format in UTC+0.
" + + "The returned events will have start_time strictly within " + + "the range of the filter
" + + "e.g 2021-10-05T12:41:37Z", + "schema": {"type": "datetime"}, + } + ) + op["parameters"].append( + { + "name": "filter_end", + "in": "query", + "required": True, + "description": "The end date of the filter in ISO format in UTC+0
" + + "e.g 2021-10-05T12:41:37Z", + "schema": {"type": "datetime"}, + } + ) + return op diff --git a/backend/ohq/serializers.py b/backend/ohq/serializers.py new file mode 100644 index 00000000..0b704ed0 --- /dev/null +++ b/backend/ohq/serializers.py @@ -0,0 +1,576 @@ +import string + +from django.contrib.auth import get_user_model +from django.core.exceptions import ObjectDoesNotExist +from django.utils import timezone +from django.utils.crypto import get_random_string +from phonenumber_field.serializerfields import PhoneNumberField +from rest_framework import serializers +from rest_live.signals import save_handler +from schedule.models import Calendar, Event, EventRelation, EventRelationManager, Rule +from schedule.models.events import Occurrence + +from ohq.models import ( + Announcement, + Course, + CourseStatistic, + Membership, + MembershipInvite, + Profile, + Question, + Queue, + QueueStatistic, + Semester, + Tag, +) +from ohq.sms import sendSMSVerification +from ohq.tasks import sendUpNextNotificationTask + + +class CourseRouteMixin(serializers.ModelSerializer): + """ + Mixin for serializers that overrides the save method to + properly handle the URL parameter for courses. + """ + + def save(self): + self.validated_data["course"] = Course.objects.get( + pk=self.context["view"].kwargs["course_pk"] + ) + return super().save() + + +class QueueRouteMixin(serializers.ModelSerializer): + """ + Mixin for serializers that overrides the save method to + properly handle the URL parameter for queues. + """ + + def save(self): + self.validated_data["queue"] = Queue.objects.get(pk=self.context["view"].kwargs["queue_pk"]) + return super().save() + + +class SemesterSerializer(serializers.ModelSerializer): + pretty = serializers.SerializerMethodField() + + class Meta: + model = Semester + fields = ("id", "year", "term", "pretty") + + def get_pretty(self, obj): + return str(obj) + + +class CourseSerializer(serializers.ModelSerializer): + semester_pretty = serializers.StringRelatedField(source="semester") + is_member = serializers.BooleanField(default=False, read_only=True) + + class Meta: + model = Course + fields = ( + "id", + "course_code", + "department", + "course_title", + "description", + "semester", + "semester_pretty", + "archived", + "invite_only", + "is_member", + ) + + +class CourseCreateSerializer(serializers.ModelSerializer): + created_role = serializers.CharField(write_only=True) + + class Meta: + model = Course + fields = ( + "id", + "course_code", + "department", + "course_title", + "description", + "semester", + "archived", + "invite_only", + "created_role", + ) + + def create(self, validated_data): + kind = validated_data.pop("created_role") + instance = super().create(validated_data) + Membership.objects.create(course=instance, user=self.context["request"].user, kind=kind) + return instance + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = get_user_model() + fields = ("first_name", "last_name", "email", "username") + + +class MembershipSerializer(CourseRouteMixin): + user = UserSerializer(read_only=True) + + class Meta: + model = Membership + fields = ("id", "kind", "time_created", "last_active", "user") + + def create(self, validated_data): + ModelClass = self.Meta.model + validated_data["user"] = self.context["request"].user + validated_data["kind"] = Membership.KIND_STUDENT + return ModelClass._default_manager.create(**validated_data) + + +class MembershipInviteSerializer(CourseRouteMixin): + class Meta: + model = MembershipInvite + fields = ("id", "email", "kind", "time_created") + + def validate_email(self, value): + return value.lower() + + +class QueueSerializer(CourseRouteMixin): + questions_active = serializers.IntegerField(default=0, read_only=True) + questions_asked = serializers.IntegerField(default=0, read_only=True) + staff_active = serializers.IntegerField(default=0, read_only=True) + + class Meta: + model = Queue + fields = ( + "id", + "name", + "description", + "question_template", + "archived", + "estimated_wait_time", + "active", + "questions_active", + "questions_asked", + "staff_active", + "rate_limit_enabled", + "rate_limit_length", + "rate_limit_questions", + "rate_limit_minutes", + "video_chat_setting", + "pin", + "pin_enabled", + ) + read_only_fields = ("estimated_wait_time",) + + def update(self, instance, validated_data): + """ + Head TAs+ can modify a queue + TAs can only modify if a queue is active. + """ + + user = self.context["request"].user + membership = Membership.objects.get(course=instance.course, user=user) + + # generate a random pin when the queue is opened and the queue has pin enabled + if "active" in validated_data and validated_data["active"] and instance.pin_enabled: + validated_data["pin"] = get_random_string( + length=5, allowed_chars=string.ascii_letters + string.digits + ) + + if membership.is_leadership: # User is a Head TA+ + return super().update(instance, validated_data) + + if "active" in validated_data: + instance.active = validated_data["active"] + + if "pin" in validated_data: + instance.pin = validated_data["pin"] + + instance.save() + return instance + + def to_representation(self, instance): + # get the original representation + rep = super(QueueSerializer, self).to_representation(instance) + + user = self.context["request"].user + membership = Membership.objects.filter(course=instance.course, user=user).first() + + if membership is None or not membership.is_ta: + rep.pop("pin") + + return rep + + +class TagSerializer(CourseRouteMixin): + class Meta: + model = Tag + fields = ("id", "name") + + +class QuestionSerializer(QueueRouteMixin): + asked_by = UserSerializer(read_only=True) + responded_to_by = UserSerializer(read_only=True) + tags = TagSerializer(many=True) + position = serializers.IntegerField(default=-1, read_only=True) + + class Meta: + model = Question + fields = ( + "id", + "text", + "video_chat_url", + "status", + "time_asked", + "asked_by", + "time_response_started", + "time_responded_to", + "responded_to_by", + "rejected_reason", + "should_send_up_soon_notification", + "tags", + "note", + "resolved_note", + "position", + "student_descriptor", + ) + read_only_fields = ( + "time_asked", + "asked_by", + "time_response_started", + "time_responded_to", + "responded_to_by", + "should_send_up_soon_notification", + "resolved_note", + "position", + ) + + def update(self, instance, validated_data): + """ + Students can update their question's text and video_chat_url or withdraw the question + TAs+ can only modify the status of a question. + """ + user = self.context["request"].user + membership = Membership.objects.get(course=instance.queue.course, user=user) + queue_id = self.context["view"].kwargs["queue_pk"] + + if membership.is_ta: # User is a TA+ + if "status" in validated_data: + status = validated_data["status"] + if status == Question.STATUS_WITHDRAWN: + raise serializers.ValidationError( + detail={"detail": "TAs can't mark a question as withdrawn"} + ) + instance.status = status + if status == Question.STATUS_ACTIVE: + instance.responded_to_by = user + instance.time_response_started = timezone.now() + elif status == Question.STATUS_REJECTED: + instance.responded_to_by = user + instance.time_response_started = timezone.now() + instance.time_responded_to = timezone.now() + instance.rejected_reason = validated_data["rejected_reason"] + sendUpNextNotificationTask.delay(queue_id) + elif status == Question.STATUS_ANSWERED: + instance.time_responded_to = timezone.now() + sendUpNextNotificationTask.delay(queue_id) + elif status == Question.STATUS_ASKED: + instance.responded_to_by = None + instance.time_response_started = None + if "note" in validated_data: + instance.note = validated_data["note"] + instance.resolved_note = False + else: # User is a student + if "status" in validated_data: + status = validated_data["status"] + if status == Question.STATUS_WITHDRAWN: + instance.status = status + sendUpNextNotificationTask.delay(queue_id) + elif status == Question.STATUS_ANSWERED: + instance.status = status + instance.time_responded_to = timezone.now() + else: + raise serializers.ValidationError( + detail={"detail": "Students can only withdraw a question"} + ) + if "text" in validated_data: + instance.text = validated_data["text"] + if "video_chat_url" in validated_data: + instance.video_chat_url = validated_data["video_chat_url"] + if "tags" in validated_data: + instance.tags.clear() + for tag_data in validated_data.pop("tags"): + try: + tag = Tag.objects.get(course=instance.queue.course, **tag_data) + instance.tags.add(tag) + except ObjectDoesNotExist: + continue + if "student_descriptor" in validated_data: + instance.student_descriptor = validated_data["student_descriptor"] + # If a student modifies a question, discard any note added by a TA and mark as resolved + instance.note = "" + instance.resolved_note = True + + instance.save() + + # if the status changes to something that affects position, call save() on asked questions + if "status" in validated_data: + asked_questions = Question.objects.filter( + queue=instance.queue, status=Question.STATUS_ASKED + ) + + for question in asked_questions: + save_handler(sender=Question, instance=question, dispatch_uid="rest-live") + + return instance + + def create(self, validated_data): + tags = validated_data.pop("tags") + queue = Queue.objects.get(pk=self.context["view"].kwargs["queue_pk"]) + questions_ahead = Question.objects.filter( + queue=queue, status=Question.STATUS_ASKED, time_asked__lt=timezone.now() + ).count() + validated_data["should_send_up_soon_notification"] = questions_ahead >= 4 + validated_data["status"] = Question.STATUS_ASKED + validated_data["asked_by"] = self.context["request"].user + question = super().create(validated_data) + for tag_data in tags: + try: + tag = Tag.objects.get(course=queue.course, **tag_data) + question.tags.add(tag) + except ObjectDoesNotExist: + continue + return question + + +class MembershipPrivateSerializer(CourseRouteMixin): + """ + Private serializer that contains course information + """ + + course = CourseSerializer(read_only=True) + + class Meta: + model = Membership + fields = ("id", "course", "kind", "time_created", "last_active") + + +class ProfileSerializer(serializers.ModelSerializer): + phone_number = PhoneNumberField() + + class Meta: + model = Profile + fields = ( + "sms_notifications_enabled", + "sms_verified", + "sms_verification_code", + "phone_number", + ) + extra_kwargs = {"sms_verification_code": {"write_only": True}} + + +class UserPrivateSerializer(serializers.ModelSerializer): + """ + Private serializer to allow users to see/modify their profiles. + """ + + profile = ProfileSerializer(read_only=False, required=False) + membership_set = MembershipPrivateSerializer(many=True, read_only=True) + groups = serializers.StringRelatedField(many=True, read_only=True) + + class Meta: + model = get_user_model() + fields = ( + "id", + "first_name", + "last_name", + "email", + "username", + "profile", + "membership_set", + "groups", + ) + + def update(self, instance, validated_data): + if "profile" in validated_data: + profile_fields = validated_data.pop("profile") + profile = instance.profile + # Set sms notifications enabled + if "sms_notifications_enabled" in profile_fields: + profile.sms_notifications_enabled = profile_fields["sms_notifications_enabled"] + + # Handle new phone number + if ( + "phone_number" in profile_fields + and profile_fields["phone_number"] != profile.phone_number + ): + profile.phone_number = profile_fields["phone_number"] + profile.sms_verified = False + profile.sms_verification_code = get_random_string( + length=6, allowed_chars="1234567890" + ) + profile.sms_verification_timestamp = timezone.now() + sendSMSVerification(profile.phone_number, profile.sms_verification_code) + + # Handle SMS verification + if "sms_verification_code" in profile_fields: + elapsed_time = timezone.now() - profile.sms_verification_timestamp + if ( + profile_fields["sms_verification_code"] == profile.sms_verification_code + and elapsed_time.total_seconds() + < Profile.SMS_VERIFICATION_EXPIRATION_MINUTES * 60 + ): + profile.sms_verified = True + elif ( + elapsed_time.total_seconds() >= Profile.SMS_VERIFICATION_EXPIRATION_MINUTES * 60 + ): + raise serializers.ValidationError( + detail={"detail": "Verification code has expired"} + ) + else: + raise serializers.ValidationError( + detail={"detail": "Incorrect verification code"} + ) + + profile.save() + return super().update(instance, validated_data) + + +class CourseStatisticSerializer(serializers.ModelSerializer): + class Meta: + model = CourseStatistic + fields = ("user", "metric", "value", "date") + # make everything read-only, stats are only updated through commands + read_only_fields = ("user", "metric", "value", "date") + + +class QueueStatisticSerializer(serializers.ModelSerializer): + class Meta: + model = QueueStatistic + fields = ("metric", "day", "hour", "value", "date") + # make everything read-only, stats are only updated through commands + read_only_fields = ("metric", "day", "hour", "value", "date") + + +class AnnouncementSerializer(CourseRouteMixin): + """ + Serializer for announcements + """ + + author = UserSerializer(read_only=True) + + class Meta: + model = Announcement + fields = ("id", "content", "author", "time_updated") + read_only_fields = ("author",) + + def create(self, validated_data): + validated_data["author"] = self.context["request"].user + return super().create(validated_data) + + def update(self, instance, validated_data): + validated_data["author"] = self.context["request"].user + return super().update(instance, validated_data) + + +class RuleSerializer(serializers.ModelSerializer): + """ + Serializer for rules + """ + + class Meta: + model = Rule + fields = ("frequency",) + + +class EventSerializer(serializers.ModelSerializer): + """ + Serializer for events + + All times are converted to UTC+0 + """ + + rule = RuleSerializer(required=False) + course_id = serializers.IntegerField(required=False) + + class Meta: + model = Event + fields = ( + "id", + "start", + "end", + "title", + "description", + "rule", + "end_recurring_period", + "course_id", + ) + + def to_representation(self, instance): + representation = super().to_representation(instance) + course_pk = EventRelation.objects.filter(event=instance).first().object_id + representation["course_id"] = course_pk + return representation + + def update(self, instance, validated_data): + rule = instance.rule + # if changing start, end, or update, delete all previous occurrences + if ( + ("start" in validated_data and instance.start != validated_data["start"]) + or ("end" in validated_data and instance.end != validated_data["end"]) + or ( + "end_recurring_period" in validated_data + and instance.end_recurring_period != validated_data["end_recurring_period"] + ) + or ( + "rule" in validated_data + and (rule is None or rule.frequency != validated_data["rule"]["frequency"]) + ) + ): + if "rule" in validated_data: + rule, _ = Rule.objects.get_or_create(frequency=validated_data["rule"]["frequency"]) + validated_data.pop("rule") + Occurrence.objects.filter(event=instance).delete() + + # can never change course_id, client should create a new event instead + validated_data.pop("course_id") + super().update(instance, validated_data) + + instance.rule = rule + instance.save() + return instance + + def create(self, validated_data): + course = Course.objects.get(pk=validated_data["course_id"]) + rule = None + if "rule" in validated_data: + rule, _ = Rule.objects.get_or_create(frequency=validated_data["rule"]["frequency"]) + validated_data.pop("rule") + + validated_data.pop("course_id") + default_calendar = Calendar.objects.filter(name="DefaultCalendar").first() + if default_calendar is None: + default_calendar = Calendar.objects.create(name="DefaultCalendar") + validated_data["calendar"] = default_calendar + + # for some reason, super().create() doesn't automatically serialize Rule + event = super().create(validated_data) + event.rule = rule + event.save() + + erm = EventRelationManager() + erm.create_relation(event=event, content_object=course) + return event + + +class OccurrenceSerializer(serializers.ModelSerializer): + """ + Serializer for occurrence + """ + + event = EventSerializer(read_only=True) + + class Meta: + model = Occurrence + fields = ("id", "title", "description", "start", "end", "cancelled", "event") diff --git a/backend/ohq/sms.py b/backend/ohq/sms.py new file mode 100644 index 00000000..04618bad --- /dev/null +++ b/backend/ohq/sms.py @@ -0,0 +1,25 @@ +from django.conf import settings +from sentry_sdk import capture_message +from twilio.base.exceptions import TwilioException, TwilioRestException +from twilio.rest import Client + + +def sendSMS(to, body): + try: + client = Client(settings.TWILIO_SID, settings.TWILIO_AUTH_TOKEN) + client.messages.create(to=str(to), from_=settings.TWILIO_NUMBER, body=body) + except TwilioRestException as e: + capture_message(e, level="error") + except TwilioException as e: # likely a credential issue in development + capture_message(e, level="error") + + +def sendSMSVerification(to, verification_code): + body = f"Your OHQ Verification Code is: {verification_code}" + sendSMS(to, body) + + +def sendUpNextNotification(user, course): + course_title = f"{course.department} {course.course_code}" + body = f"You are currently 3rd in line for {course_title}, be ready soon!" + sendSMS(user.profile.phone_number, body) diff --git a/backend/ohq/statistics.py b/backend/ohq/statistics.py new file mode 100644 index 00000000..e610006d --- /dev/null +++ b/backend/ohq/statistics.py @@ -0,0 +1,227 @@ +from django.contrib.auth import get_user_model +from django.db.models import Avg, Case, Count, F, Sum, When +from django.db.models.functions import TruncDate +from django.utils import timezone + +from ohq.models import CourseStatistic, Question, QueueStatistic + + +User = get_user_model() + + +def course_calculate_student_most_questions_asked(course, last_sunday): + next_sunday = last_sunday + timezone.timedelta(days=7) + student_most_questions = ( + Question.objects.filter( + queue__course=course, time_asked__gte=last_sunday, time_asked__lt=next_sunday + ) + .values("asked_by") + .annotate(questions_asked=Count("asked_by")) + .order_by("-questions_asked")[:5] + ) + + for q in student_most_questions: + student_pk = q["asked_by"] + user = User.objects.get(pk=student_pk) + num_questions = q["questions_asked"] + + CourseStatistic.objects.update_or_create( + course=course, + user=user, + metric=CourseStatistic.METRIC_STUDENT_QUESTIONS_ASKED, + date=last_sunday, + defaults={"value": num_questions}, + ) + + +def course_calculate_student_most_time_being_helped(course, last_sunday): + next_sunday = last_sunday + timezone.timedelta(days=7) + student_most_time = ( + Question.objects.filter( + queue__course=course, + time_responded_to__gte=last_sunday, + time_asked__lt=next_sunday, + status=Question.STATUS_ANSWERED, + ) + .values("asked_by") + .annotate(time_being_helped=Sum(F("time_responded_to") - F("time_response_started"))) + .order_by("-time_being_helped")[:5] + ) + + for q in student_most_time: + student_pk = q["asked_by"] + user = User.objects.get(pk=student_pk) + time = q["time_being_helped"].seconds + + CourseStatistic.objects.update_or_create( + course=course, + user=user, + metric=CourseStatistic.METRIC_STUDENT_TIME_BEING_HELPED, + date=last_sunday, + defaults={"value": time}, + ) + + +def course_calculate_instructor_most_questions_answered(course, last_sunday): + next_sunday = last_sunday + timezone.timedelta(days=7) + instructor_most_questions = ( + Question.objects.filter( + queue__course=course, + time_responded_to__gte=last_sunday, + time_asked__lt=next_sunday, + status=Question.STATUS_ANSWERED, + ) + .exclude(responded_to_by=None) + .values("responded_to_by") + .annotate(questions_answered=Count("responded_to_by")) + .order_by("-questions_answered")[:5] + ) + + for q in instructor_most_questions: + instructor_pk = q["responded_to_by"] + user = User.objects.get(pk=instructor_pk) + num_questions = q["questions_answered"] + + CourseStatistic.objects.update_or_create( + course=course, + user=user, + metric=CourseStatistic.METRIC_INSTR_QUESTIONS_ANSWERED, + date=last_sunday, + defaults={"value": num_questions}, + ) + + +def course_calculate_instructor_most_time_helping(course, last_sunday): + next_sunday = last_sunday + timezone.timedelta(days=7) + instructor_most_time = ( + Question.objects.filter( + queue__course=course, + time_responded_to__gte=last_sunday, + time_asked__lt=next_sunday, + status=Question.STATUS_ANSWERED, + ) + .exclude(responded_to_by=None) + .values("responded_to_by") + .annotate(time_answering=Sum(F("time_responded_to") - F("time_response_started"))) + .order_by("-time_answering")[:5] + ) + + for q in instructor_most_time: + instructor_pk = q["responded_to_by"] + user = User.objects.get(pk=instructor_pk) + time = q["time_answering"].seconds + + CourseStatistic.objects.update_or_create( + course=course, + user=user, + metric=CourseStatistic.METRIC_INSTR_TIME_ANSWERING, + date=last_sunday, + defaults={"value": time}, + ) + + +def queue_calculate_avg_wait(queue, date): + avg = Question.objects.filter( + queue=queue, time_asked__date=date, time_response_started__isnull=False, + ).aggregate(avg_wait=Avg(F("time_response_started") - F("time_asked"))) + + wait = avg["avg_wait"] + + QueueStatistic.objects.update_or_create( + queue=queue, + metric=QueueStatistic.METRIC_AVG_WAIT, + date=date, + defaults={"value": wait.seconds if wait else 0}, + ) + + +def queue_calculate_avg_time_helping(queue, date): + avg = Question.objects.filter( + queue=queue, + status=Question.STATUS_ANSWERED, + time_response_started__date=date, + time_responded_to__isnull=False, + ).aggregate(avg_time=Avg(F("time_responded_to") - F("time_response_started"))) + + duration = avg["avg_time"] + + QueueStatistic.objects.update_or_create( + queue=queue, + metric=QueueStatistic.METRIC_AVG_TIME_HELPING, + date=date, + defaults={"value": duration.seconds if duration else 0}, + ) + + +def queue_calculate_wait_time_heatmap(queue, weekday, hour): + interval_avg = Question.objects.filter( + queue=queue, + time_asked__week_day=weekday, + time_asked__hour=hour, + time_response_started__isnull=False, + ).aggregate(avg_wait=Avg(F("time_response_started") - F("time_asked"))) + + interval_avg_wait = interval_avg["avg_wait"] + + QueueStatistic.objects.update_or_create( + queue=queue, + metric=QueueStatistic.METRIC_HEATMAP_WAIT, + day=weekday, + hour=hour, + defaults={"value": interval_avg_wait.seconds if interval_avg_wait else 0}, + ) + + +def queue_calculate_num_questions_ans(queue, date): + num_questions = Question.objects.filter( + queue=queue, status=Question.STATUS_ANSWERED, time_responded_to__date=date, + ).count() + + QueueStatistic.objects.update_or_create( + queue=queue, + metric=QueueStatistic.METRIC_NUM_ANSWERED, + date=date, + defaults={"value": num_questions}, + ) + + +def queue_calculate_num_students_helped(queue, date): + num_students = ( + Question.objects.filter( + queue=queue, status=Question.STATUS_ANSWERED, time_responded_to__date=date, + ) + .distinct("asked_by") + .count() + ) + + QueueStatistic.objects.update_or_create( + queue=queue, + metric=QueueStatistic.METRIC_STUDENTS_HELPED, + date=date, + defaults={"value": num_students}, + ) + + +def queue_calculate_questions_per_ta_heatmap(queue, weekday, hour): + interval_stats = ( + Question.objects.filter(queue=queue, time_asked__week_day=weekday, time_asked__hour=hour) + .annotate(date=TruncDate("time_asked")) + .values("date") + .annotate( + questions=Count("date", distinct=False), tas=Count("responded_to_by", distinct=True), + ) + .annotate( + q_per_ta=Case(When(tas=0, then=F("questions")), default=1.0 * F("questions") / F("tas")) + ) + .aggregate(avg=Avg(F("q_per_ta"))) + ) + + statistic = interval_stats["avg"] + + QueueStatistic.objects.update_or_create( + queue=queue, + metric=QueueStatistic.METRIC_HEATMAP_QUESTIONS_PER_TA, + day=weekday, + hour=hour, + defaults={"value": statistic if statistic else 0}, + ) diff --git a/backend/ohq/tasks.py b/backend/ohq/tasks.py new file mode 100644 index 00000000..412dc7c6 --- /dev/null +++ b/backend/ohq/tasks.py @@ -0,0 +1,21 @@ +from celery import shared_task + +from ohq.models import Question +from ohq.sms import sendUpNextNotification + + +@shared_task(name="ohq.tasks.sendUpNextNotificationTask") +def sendUpNextNotificationTask(queue_id): + """ + Send an SMS notification to the 3rd person in a queue if they have verified their phone number + and the queue was at least 4 people long when they joined it. + """ + + questions = Question.objects.filter(queue=queue_id, status=Question.STATUS_ASKED).order_by( + "time_asked" + ) + if questions.count() >= 3: + question = questions[2] + user = question.asked_by + if question.should_send_up_soon_notification and user.profile.sms_verified: + sendUpNextNotification(user, question.queue.course) diff --git a/backend/ohq/urls.py b/backend/ohq/urls.py new file mode 100644 index 00000000..cfef9d39 --- /dev/null +++ b/backend/ohq/urls.py @@ -0,0 +1,66 @@ +from django.urls import path +from rest_framework_nested import routers +from rest_live.routers import RealtimeRouter + +from ohq.views import ( + AnnouncementViewSet, + CourseStatisticView, + CourseViewSet, + EventViewSet, + MassInviteView, + MembershipInviteViewSet, + MembershipViewSet, + OccurrenceViewSet, + QuestionSearchView, + QuestionViewSet, + QueueStatisticView, + QueueViewSet, + ResendNotificationView, + SemesterViewSet, + TagViewSet, + UserView, +) + + +app_name = "ohq" + +router = routers.SimpleRouter() +router.register("semesters", SemesterViewSet, basename="semester") +router.register("courses", CourseViewSet, basename="course") +router.register("events", EventViewSet, basename="event") +router.register("occurrences", OccurrenceViewSet, basename="occurrence") + +course_router = routers.NestedSimpleRouter(router, "courses", lookup="course") +course_router.register("queues", QueueViewSet, basename="queue") +course_router.register("members", MembershipViewSet, basename="member") +course_router.register("invites", MembershipInviteViewSet, basename="invite") +course_router.register("announcements", AnnouncementViewSet, basename="announcement") +course_router.register("tags", TagViewSet, basename="tag") + +queue_router = routers.NestedSimpleRouter(course_router, "queues", lookup="queue") +queue_router.register("questions", QuestionViewSet, basename="question") + +realtime_router = RealtimeRouter() +realtime_router.register(QuestionViewSet) +realtime_router.register(AnnouncementViewSet) + +additional_urls = [ + path("accounts/me/", UserView.as_view(), name="me"), + path("accounts/me/resend/", ResendNotificationView.as_view(), name="resend"), + path("courses//mass-invite/", MassInviteView.as_view(), name="mass-invite"), + path( + "courses//questions/", QuestionSearchView.as_view(), name="questionsearch" + ), + path( + "courses//queues//statistics/", + QueueStatisticView.as_view(), + name="queue-statistic", + ), + path( + "courses//course-statistics/", + CourseStatisticView.as_view(), + name="course-statistic", + ), +] + +urlpatterns = router.urls + course_router.urls + queue_router.urls + additional_urls diff --git a/backend/ohq/views.py b/backend/ohq/views.py new file mode 100644 index 00000000..3dbfc272 --- /dev/null +++ b/backend/ohq/views.py @@ -0,0 +1,770 @@ +import math +import re +from datetime import datetime, timedelta + +from django.contrib.auth import get_user_model +from django.core.validators import ValidationError +from django.db.models import ( + Case, + Count, + Exists, + FloatField, + IntegerField, + OuterRef, + Q, + Subquery, + When, +) +from django.http import HttpResponseBadRequest, JsonResponse +from django.utils import timezone +from django.utils.crypto import get_random_string +from django_auto_prefetching import prefetch +from django_filters.rest_framework import DjangoFilterBackend +from drf_excel.mixins import XLSXFileMixin +from drf_excel.renderers import XLSXRenderer +from pytz import utc +from rest_framework import filters, generics, mixins, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.settings import api_settings +from rest_framework.views import APIView +from rest_live.mixins import RealtimeMixin +from schedule.models import Event, EventRelationManager, Occurrence + +from ohq.filters import CourseStatisticFilter, QuestionSearchFilter, QueueStatisticFilter +from ohq.invite import parse_and_send_invites +from ohq.models import ( + Announcement, + Course, + CourseStatistic, + Membership, + MembershipInvite, + Question, + Queue, + QueueStatistic, + Semester, + Tag, +) +from ohq.pagination import QuestionSearchPagination +from ohq.permissions import ( + AnnouncementPermission, + CoursePermission, + CourseStatisticPermission, + EventPermission, + IsSuperuser, + MassInvitePermission, + MembershipInvitePermission, + MembershipPermission, + OccurrencePermission, + QuestionPermission, + QuestionSearchPermission, + QueuePermission, + QueueStatisticPermission, + TagPermission, +) +from ohq.schemas import EventSchema, MassInviteSchema, OccurrenceSchema +from ohq.serializers import ( + AnnouncementSerializer, + CourseCreateSerializer, + CourseSerializer, + CourseStatisticSerializer, + EventSerializer, + MembershipInviteSerializer, + MembershipSerializer, + OccurrenceSerializer, + Profile, + QuestionSerializer, + QueueSerializer, + QueueStatisticSerializer, + SemesterSerializer, + TagSerializer, + UserPrivateSerializer, +) +from ohq.sms import sendSMSVerification + + +User = get_user_model() + + +class UserView(generics.RetrieveUpdateAPIView): + """ + get: + Return information about the logged in user. + + update: + Update information about the logged in user. + You must specify all of the fields or use a patch request. + + patch: + Update information about the logged in user. + Only updates fields that are passed to the server. + """ + + permission_classes = [IsAuthenticated] + serializer_class = UserPrivateSerializer + + def get_object(self): + return self.request.user + + +class ResendNotificationView(generics.CreateAPIView): + """ + update: + Generate and send a new SMS verification if the current one + has expired + """ + + permission_classes = [IsAuthenticated] + serializer_class = UserPrivateSerializer + + def get_object(self): + return self.request.user + + def post(self, request, *args, **kwargs): + user = self.get_object() + elapsed_time = timezone.now() - user.profile.sms_verification_timestamp + if elapsed_time.total_seconds() > Profile.SMS_VERIFICATION_EXPIRATION_MINUTES * 60: + user.profile.sms_verification_code = get_random_string( + length=6, allowed_chars="1234567890" + ) + user.profile.sms_verification_timestamp = timezone.now() + sendSMSVerification(user.profile.phone_number, user.profile.sms_verification_code) + user.save() + return JsonResponse({"detail": "success"}) + return HttpResponseBadRequest() + + +class CourseViewSet(viewsets.ModelViewSet): + """ + retrieve: + Return a single course with all information fields present. + + list: + Return a list of courses with partial information for each course. + + create: + Create a course. + + update: + Update all fields in the course. + You must specify all of the fields or use a patch request. + + partial_update: + Update certain fields in the course. + Only specify the fields that you want to change. + + destroy: + Delete a course. Consider marking the course as archived instead of deleting the course. + """ + + permission_classes = [CoursePermission | IsSuperuser] + serializer_class = CourseSerializer + filter_backends = [filters.SearchFilter] + search_fields = ["course_code", "department", "course_title"] + + def get_serializer_class(self): + if self.action == "create": + return CourseCreateSerializer + return self.serializer_class + + def get_queryset(self): + is_member = Membership.objects.filter(course=OuterRef("pk"), user=self.request.user) + qs = ( + Course.objects.filter(Q(invite_only=False) | Q(membership__user=self.request.user)) + .distinct() + .annotate(is_member=Exists(is_member)) + ) + return prefetch(qs, self.get_serializer_class()) + + +class QuestionViewSet(viewsets.ModelViewSet, RealtimeMixin): + """ + retrieve: + Return a single question with all information fields present. + + list: + Return a list of questions specific to a queue. + Students can only see questions they submitted. + + create: + Create a question. + + update: + Update all fields in the question. + You must specify all of the fields or use a patch request. + + partial_update: + Update certain fields in the question. + Only specify the fields that you want to change. + + destroy: + Delete a question. + """ + + permission_classes = [QuestionPermission | IsSuperuser] + serializer_class = QuestionSerializer + queryset = Question.objects.none() + + def get_queryset(self): + position = ( + Question.objects.filter( + Q(queue=OuterRef("queue")) + & Q(status=Question.STATUS_ASKED) + & Q(time_asked__lte=OuterRef("time_asked")) + ) + .values("queue") + .annotate(count=Count("queue", output_field=IntegerField())) + .values("count") + ) + + qs = ( + Question.objects.filter( + Q(queue=self.kwargs["queue_pk"]) + & (Q(status=Question.STATUS_ASKED) | Q(status=Question.STATUS_ACTIVE)) + ) + .annotate( + position=Case( + When(status=Question.STATUS_ASKED, then=Subquery(position[:1]),), default=-1, + ) + ) + .order_by("time_asked") + ) + + membership = Membership.objects.get(course=self.kwargs["course_pk"], user=self.request.user) + + if not membership.is_ta: + qs = qs.filter(asked_by=self.request.user) + return prefetch(qs, self.serializer_class) + + @action(detail=True) + def position(self, request, course_pk, queue_pk, pk=None): + """ + Get the position of a question within its queue. + """ + question = self.get_object() + position = -1 + if question.status == Question.STATUS_ASKED: + position = ( + Question.objects.filter( + queue=queue_pk, status=Question.STATUS_ASKED, time_asked__lt=question.time_asked + ).count() + + 1 + ) + return JsonResponse({"position": position}) + + @action(detail=False) + def last(self, request, course_pk, queue_pk): + """ + Get the last question you asked in a queue. Only visible to Students. + """ + + queryset = Question.objects.filter( + Q(queue=queue_pk) + & Q(asked_by=request.user) + & ( + Q(status=Question.STATUS_WITHDRAWN) + | Q(status=Question.STATUS_REJECTED) + | Q(status=Question.STATUS_ANSWERED) + ) + ).order_by("-time_asked")[:1] + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + + def list(self, request, *args, **kwargs): + """ + Update a staff member's last active time when they view questions + """ + + membership = Membership.objects.get(user=request.user, course=self.kwargs["course_pk"]) + membership.last_active = timezone.now() + membership.save() + return super().list(request, *args, **kwargs) + + def quota_count_helper(self, queue, user): + """ + Helper to get the questions within the quota period for queues with quotas + """ + + return Question.objects.filter( + queue=queue, + asked_by=user, + time_responded_to__gte=timezone.now() - timedelta(minutes=queue.rate_limit_minutes), + ).exclude(status__in=[Question.STATUS_REJECTED, Question.STATUS_WITHDRAWN]) + + def create(self, request, *args, **kwargs): + """ + Create a new question and check if it follows the rate limit + """ + + queue = Queue.objects.get(id=self.kwargs["queue_pk"]) + if ( + queue.rate_limit_enabled + and Question.objects.filter(queue=queue, status=Question.STATUS_ASKED).count() + >= queue.rate_limit_length + ): + num_questions_asked = self.quota_count_helper(queue, request.user).count() + + if num_questions_asked >= queue.rate_limit_questions: + return JsonResponse({"detail": "rate limited"}, status=429) + if queue.pin_enabled and queue.pin != request.data.get("pin"): + return JsonResponse({"detail": "incorrect pin"}, status=409) + + return super().create(request, *args, **kwargs) + + @action(detail=False) + def quota_count(self, request, course_pk, queue_pk): + """ + Get number of questions asked within rate limit period if it is set up for the queue + """ + + queue = Queue.objects.get(id=queue_pk) + if queue.rate_limit_enabled: + questions = self.quota_count_helper(queue, request.user) + count = questions.count() + + wait_time_mins = 0 + if ( + Question.objects.filter(queue=queue, status=Question.STATUS_ASKED).count() + >= queue.rate_limit_length + and count >= queue.rate_limit_questions + ): + last_question = questions.order_by("-time_responded_to")[ + queue.rate_limit_questions - 1 + ] + wait_time_secs = ( + queue.rate_limit_minutes * 60 + - (timezone.now() - last_question.time_responded_to).total_seconds() + ) + wait_time_mins = math.ceil(wait_time_secs / 60) + + return JsonResponse({"count": count, "wait_time_mins": wait_time_mins}) + else: + return JsonResponse({"detail": "queue does not have rate limit"}, status=405) + + +class QuestionSearchView(XLSXFileMixin, generics.ListAPIView): + filter_backends = [DjangoFilterBackend] + filterset_class = QuestionSearchFilter + pagination_class = QuestionSearchPagination + permission_classes = [QuestionSearchPermission | IsSuperuser] + renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + [XLSXRenderer] + serializer_class = QuestionSerializer + filename = "questions.xlsx" + + def get_queryset(self): + qs = Question.objects.filter( + queue__in=Queue.objects.filter(course=self.kwargs["course_pk"]) + ).order_by("time_asked") + return prefetch(qs, self.serializer_class) + + @property + def paginator(self): + """ + Removes pagination from the XLSX Render so that TAs can download a full list + of asked questions. + https://www.django-rest-framework.org/api-guide/renderers/#varying-behaviour-by-media-type + """ + + if self.request.accepted_renderer.format == "xlsx": # xlsx download + self._paginator = None + return None + return super().paginator + + +class QueueViewSet(viewsets.ModelViewSet): + """ + retrieve: + Return a single queue. + + list: + Return a list of queues specific to a course. + + create: + Create a queue. + + update: + Update all fields in the queue. + You must specify all of the fields or use a patch request. + + partial_update: + Update certain fields in the queue. + Only specify the fields that you want to change. + + destroy: + Delete a queue. + """ + + permission_classes = [QueuePermission | IsSuperuser] + serializer_class = QueueSerializer + + def get_queryset(self): + """ + Annotate the number of questions asked, number of questioned currently being answered, + and the number of active staff members. + Filter/annotation pattern taken from here: + https://stackoverflow.com/questions/42543978/django-1-11-annotating-a-subquery-aggregate + """ + + questions = ( + Question.objects.filter(queue=OuterRef("pk")) + .order_by() + .values("queue") + .annotate(count=Count("*")) + .values("count") + ) + questions_active = questions.filter(status=Question.STATUS_ACTIVE) + questions_asked = questions.filter(status=Question.STATUS_ASKED) + + time_threshold = timezone.now() - timedelta(minutes=1) + staff_active = ( + Membership.objects.filter( + Q(course=OuterRef("course__pk")) + & ~Q(kind=Membership.KIND_STUDENT) + & Q(last_active__gt=time_threshold) + ) + .order_by() + .values("course") + .annotate(count=Count("*", output_field=FloatField()),) + .values("count") + ) + + qs = ( + Queue.objects.filter(course=self.kwargs["course_pk"], archived=False) + .annotate( + questions_active=Subquery(questions_active[:1], output_field=IntegerField()), + questions_asked=Subquery(questions_asked[:1]), + staff_active=Subquery(staff_active[:1]), + ) + .order_by("id") + ) + + return prefetch(qs, self.serializer_class) + + @action(methods=["POST"], detail=True) + def clear(self, request, course_pk, pk=None): + """ + Clear the queue by rejecting all questions which are currently open (in the asked state). + """ + queue = self.get_object() + Question.objects.filter(queue=queue, status=Question.STATUS_ASKED).update( + status=Question.STATUS_REJECTED, + rejected_reason="OH_ENDED", + responded_to_by=self.request.user, + ) + return JsonResponse({"detail": "success"}) + + +class TagViewSet(viewsets.ModelViewSet): + """ + retrieve: + Return a single tag with all information fields present. + + list: + Return a list of tags specific to a course. + + create: + Create a tag. + + update: + Update all fields in the tag. + You must specify all of the fields or use a patch request. + + partial_update: + Update certain fields in the tag. + Only specify the fields that you want to change. + + destroy: + Delete a tag. + """ + + permission_classes = [TagPermission | IsSuperuser] + serializer_class = TagSerializer + + def get_queryset(self): + qs = Tag.objects.filter(course=self.kwargs["course_pk"]) + return prefetch(qs, self.serializer_class) + + +class MembershipViewSet(viewsets.ModelViewSet): + """ + retrieve: + Return a single membership. + + list: + Return a list of memberships specific to a course. Students cannot see + if peers are members or not. + + create: + Create a membership. + + update: + Update all fields in the membership. + You must specify all of the fields or use a patch request. + + partial_update: + Update certain fields in the membership. + Only specify the fields that you want to change. + + destroy: + Delete a membership. + """ + + permission_classes = [MembershipPermission | IsSuperuser] + serializer_class = MembershipSerializer + + def get_queryset(self): + qs = Membership.objects.filter(course=self.kwargs["course_pk"]).order_by("user__first_name") + + membership = Membership.objects.get(course=self.kwargs["course_pk"], user=self.request.user) + + if not membership.is_ta: + qs = qs.filter( + Q(kind=Membership.KIND_PROFESSOR) + | Q(kind=Membership.KIND_HEAD_TA) + | Q(user=self.request.user) + ) + return prefetch(qs, self.serializer_class) + + +class MembershipInviteViewSet(viewsets.ModelViewSet): + """ + retrieve: + Return a single membership invite. + + list: + Return a list of membership invites specific to a course. + + create: + Create a membership invite. + + update: + Update all fields in the membership invite. + You must specify all of the fields or use a patch request. + + partial_update: + Update certain fields in the membership invite. + Only specify the fields that you want to change. + + destroy: + Delete a membership invite. + """ + + permission_classes = [MembershipInvitePermission | IsSuperuser] + serializer_class = MembershipInviteSerializer + + def get_queryset(self): + return MembershipInvite.objects.filter(course=self.kwargs["course_pk"]) + + +class SemesterViewSet(viewsets.ReadOnlyModelViewSet): + """ + list: + Get all semesters + + retrieve: + Get a specific semester + """ + + serializer_class = SemesterSerializer + queryset = Semester.objects.all() + + +class MassInviteView(APIView): + """ + Sends out invitations to join a course to multiple recipients. + """ + + permission_classes = [MassInvitePermission | IsSuperuser] + schema = MassInviteSchema() + + def post(self, request, course_pk, format=None): + kind = request.data.get("kind") + course = Course.objects.get(id=self.kwargs["course_pk"]) + # Get list of emails + emails = [x.strip() for x in re.split("\n|,", request.data.get("emails", ""))] + emails = [x for x in emails if x] + + try: + members_added, invites_sent = parse_and_send_invites(course, emails, kind) + except ValidationError: + return Response({"detail": "invalid emails"}, status=400) + + return Response( + data={ + "detail": "success", + "members_added": members_added, + "invites_sent": invites_sent, + }, + status=201, + ) + + +class CourseStatisticView(generics.ListAPIView): + """ + Return a list of statistics - multiple data points for list statistics and heatmap statistics + and singleton for card statistics. + """ + + serializer_class = CourseStatisticSerializer + filter_backends = [DjangoFilterBackend] + filterset_class = CourseStatisticFilter + permission_classes = [CourseStatisticPermission | IsSuperuser] + + def get_queryset(self): + qs = CourseStatistic.objects.filter(course=self.kwargs["course_pk"]) + return prefetch(qs, self.serializer_class) + + +class QueueStatisticView(generics.ListAPIView): + """ + Return a list of statistics - multiple data points for list statistics and heatmap statistics + and singleton for card statistics. + """ + + serializer_class = QueueStatisticSerializer + filter_backends = [DjangoFilterBackend] + filterset_class = QueueStatisticFilter + permission_classes = [QueueStatisticPermission | IsSuperuser] + + def get_queryset(self): + # might need to change qs if students shouldn't be able to see all queue statistics + qs = QueueStatistic.objects.filter(queue=self.kwargs["queue_pk"]) + return prefetch(qs, self.serializer_class) + + +class AnnouncementViewSet(viewsets.ModelViewSet, RealtimeMixin): + """ + retrieve: + Return a single announcement. + + list: + Return a list of announcements specific to a course. + + create: + Create a announcement. + + update: + Update all fields in the announcement. + You must specify all of the fields or use a patch request. + + partial_update: + Update certain fields in the announcement. + Only specify the fields that you want to change. + + destroy: + Delete a announcement. + """ + + permission_classes = [AnnouncementPermission | IsSuperuser] + serializer_class = AnnouncementSerializer + queryset = Announcement.objects.none() + + def get_queryset(self): + return Announcement.objects.filter(course=self.kwargs["course_pk"]) + + +class EventViewSet(viewsets.ModelViewSet): + """ + retrieve: + Return an event. + eventId is required + + list: + Return a list of events associated with a course. + + create: + Create a event. + courseId is required in body + + update: + Update all fields in the event. + You must specify all of the fields or use a patch request. + courseId is required in post body for authentication + + partial_update: + Update certain fields in the event. + You can update the rule's frequency, but cannot make a reoccurring event happen only once. + courseId is required in post body for authentication + + destroy: + Delete an event. + eventId is required + """ + + serializer_class = EventSerializer + permission_classes = [EventPermission | IsSuperuser] + schema = EventSchema() + + def list(self, request, *args, **kwargs): + course_ids = request.GET.getlist("course") + courses = Course.objects.filter(pk__in=course_ids) + erm = EventRelationManager() + + events = [] + for course in courses: + events_for_course = erm.get_events_for_object(course) + for event in events_for_course: + events.append(event) + + serializer = EventSerializer(events, many=True) + return JsonResponse(serializer.data, safe=False) + + def get_queryset(self): + return Event.objects.filter(pk=self.kwargs["pk"]) + + +class OccurrenceViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + viewsets.GenericViewSet, +): + """ + retrieve: + Return an Occurrence. + + list: + You should pass in a list of course ids, along with the filter start and end dates, + and all the occurrences related to those courses will be returned to you. + Return a list of Occurrences. + + update: + Update all fields in an Occurrence. + You must specify all of the fields or use a patch request. + + partial_update: + Update certain fields in the Occurrece. + """ + + serializer_class = OccurrenceSerializer + permission_classes = [OccurrencePermission | IsSuperuser] + schema = OccurrenceSchema() + + def list(self, request, *args, **kwargs): + # ensure timezone consitency + course_ids = request.GET.getlist("course") + filter_start = datetime.strptime( + request.GET.get("filter_start"), "%Y-%m-%dT%H:%M:%SZ" + ).replace(tzinfo=utc) + filter_end = datetime.strptime(request.GET.get("filter_end"), "%Y-%m-%dT%H:%M:%SZ").replace( + tzinfo=utc + ) + courses = Course.objects.filter(pk__in=course_ids) + erm = EventRelationManager() + occurrences = [] + for course in courses: + events_for_course = erm.get_events_for_object(course) + for event in events_for_course: + for occurrence in event.get_occurrences(filter_start, filter_end): + # need to save because get_occurrences only create temporary Occurrence objs + # once we save the Occurrence objs, later calls will retrieve them, + # and no duplicates will be created + occurrence.save() + occurrences.append(occurrence) + + serializer = OccurrenceSerializer(occurrences, many=True) + return JsonResponse(serializer.data, safe=False) + + def get_queryset(self): + return Occurrence.objects.filter(pk=self.kwargs["pk"]) diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 00000000..aa4949aa --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,2 @@ +[tool.black] +line-length = 100 diff --git a/backend/scripts/asgi-run b/backend/scripts/asgi-run new file mode 100755 index 00000000..3bb8df13 --- /dev/null +++ b/backend/scripts/asgi-run @@ -0,0 +1,10 @@ +#!/bin/bash + +# Django Migrate +/usr/local/bin/python3 /app/manage.py migrate --noinput + +# Switch to project folder +cd /app/ + +# Run Uvicorn through Gunicorn +exec /usr/local/bin/gunicorn -b 0.0.0.0:80 -w 4 -k uvicorn.workers.UvicornWorker officehoursqueue.asgi:application diff --git a/backend/setup.cfg b/backend/setup.cfg new file mode 100644 index 00000000..6747fc02 --- /dev/null +++ b/backend/setup.cfg @@ -0,0 +1,25 @@ +[flake8] +max-line-length = 100 +exclude = .venv, migrations +inline-quotes = double + +[isort] +default_section = THIRDPARTY +known_first_party = ohq, officehoursqueue +line_length = 100 +lines_after_imports = 2 +multi_line_output = 3 +include_trailing_comma = True +use_parentheses = True + +[coverage:run] +omit = */tests/*, */migrations/*, */settings/*, */asgi.py, */wsgi.py, */apps.py, */schemas.py, */.venv/*, manage.py, */management/commands/populate.py +source = . + +[uwsgi] +http-socket = :80 +chdir = /app/ +module = officehoursqueue.wsgi:application +master = true +static-map = /assets=/app/static +processes = 5 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/ohq/__init__.py b/backend/tests/ohq/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/ohq/test_backends.py b/backend/tests/ohq/test_backends.py new file mode 100644 index 00000000..23deb924 --- /dev/null +++ b/backend/tests/ohq/test_backends.py @@ -0,0 +1,34 @@ +from django.contrib import auth +from django.test import TestCase + +from ohq.models import Course, Membership, MembershipInvite, Semester + + +class BackendTestCase(TestCase): + def setUp(self): + self.remote_user = { + "pennid": 1, + "first_name": "First", + "last_name": "Last", + "username": "user", + "email": "user@seas.upenn.edu", + "affiliation": [], + "user_permissions": [], + "groups": ["student", "member"], + "token": {"access_token": "abc", "refresh_token": "123", "expires_in": 100}, + } + + def test_convert_invites(self): + semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + course = Course.objects.create( + course_code="000", department="TEST", course_title="Title", semester=semester + ) + MembershipInvite.objects.create( + course=course, kind=Membership.KIND_PROFESSOR, email="user@seas.upenn.edu" + ) + user = auth.authenticate(remote_user=self.remote_user) + self.assertEqual(MembershipInvite.objects.all().count(), 0) + self.assertEqual(Membership.objects.all().count(), 1) + membership = Membership.objects.get(course=course) + self.assertEqual(membership.kind, Membership.KIND_PROFESSOR) + self.assertEqual(membership.user, user) diff --git a/backend/tests/ohq/test_commands.py b/backend/tests/ohq/test_commands.py new file mode 100644 index 00000000..d4c98cad --- /dev/null +++ b/backend/tests/ohq/test_commands.py @@ -0,0 +1,1092 @@ +from datetime import datetime +from io import StringIO +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.core.management import call_command +from django.core.management.base import CommandError +from django.test import TestCase +from django.utils import timezone + +from ohq.models import ( + Course, + CourseStatistic, + Membership, + MembershipInvite, + Question, + Queue, + QueueStatistic, + Semester, +) + + +User = get_user_model() + + +@patch("ohq.management.commands.calculatewaittimes.calculate_wait_times") +class CalculateWaitTimesTestCase(TestCase): + def test_call_command(self, mock_calculate): + out = StringIO() + call_command("calculatewaittimes", stdout=out) + mock_calculate.assert_called() + self.assertEqual("Updated estimated queue wait times!\n", out.getvalue()) + + +class RegisterClassTestCase(TestCase): + def setUp(self): + self.course = ("CIS", "160", "Math", "FALL", "2020") + + Semester.objects.create(year="2020", term="FALL") + User.objects.create_user("prof1", "prof1@a.com", "prof1") + User.objects.create_user("head1", "head1@a.com", "head1") + + def test_input_email_role_length_mismatch(self): + with self.assertRaises(CommandError): + call_command( + "createcourse", + *self.course, + emails=["a@b.com", "a@c.com"], + roles=[Membership.KIND_PROFESSOR], + ) + + def test_register_class(self): + out = StringIO() + call_command( + "createcourse", + *self.course, + emails=["prof1@a.com", "head1@a.com", "prof2@a.com", "head2@a.com"], + roles=[ + Membership.KIND_PROFESSOR, + Membership.KIND_HEAD_TA, + Membership.KIND_PROFESSOR, + Membership.KIND_HEAD_TA, + ], + stdout=out, + ) + course = Course.objects.get(department="CIS", course_code="160") + self.assertEqual(course.course_title, "Math") + self.assertEqual(course.semester.year, 2020) + self.assertEqual(course.semester.term, "FALL") + + prof1 = Membership.objects.get( + course__course_code="160", course__department="CIS", user__email="prof1@a.com" + ) + prof2 = MembershipInvite.objects.get( + course__course_code="160", course__department="CIS", email="prof2@a.com" + ) + head1 = Membership.objects.get( + course__course_code="160", course__department="CIS", user__email="head1@a.com" + ) + head2 = MembershipInvite.objects.get( + course__course_code="160", course__department="CIS", email="head2@a.com" + ) + + self.assertEqual(Membership.KIND_PROFESSOR, prof1.kind) + self.assertEqual(Membership.KIND_PROFESSOR, prof2.kind) + self.assertEqual(Membership.KIND_HEAD_TA, head1.kind) + self.assertEqual(Membership.KIND_HEAD_TA, head2.kind) + + course_msg = "Created new course 'CIS 160: Fall 2020'" + prof_msg = "Added 1 professor(s) and invited 1 professor(s)" + ta_msg = "Added 1 Head TA(s) and invited 1 Head TA(s)" + + stdout_msg = course_msg + "\n" + prof_msg + "\n" + ta_msg + "\n" + + self.assertEqual(stdout_msg, out.getvalue()) + + +class QueueAverageWaitTimeTestCase(TestCase): + def setUp(self): + semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + course = Course.objects.create( + course_code="000", department="TEST", course_title="Title", semester=semester + ) + self.queue = Queue.objects.create(name="Queue", course=course) + ta = User.objects.create_user("ta", "ta@a.com", "ta") + student = User.objects.create_user("student", "student@a.com", "student") + + yesterday = timezone.localtime() - timezone.timedelta(days=1) + + # this command computes avg wait time yesterday + self.wait_times = [100, 200, 300, 400] + for i in range(len(self.wait_times)): + # test all varieties of statuses + q1 = Question.objects.create( + text=f"Question {i}", + queue=self.queue, + asked_by=student, + responded_to_by=ta, + time_response_started=yesterday + timezone.timedelta(seconds=self.wait_times[i]), + status=Question.STATUS_ACTIVE, + ) + q1.time_asked = yesterday + q1.save() + + q2 = Question.objects.create( + text=f"Question {i + len(self.wait_times)}", + queue=self.queue, + asked_by=student, + responded_to_by=ta, + time_response_started=yesterday + + timezone.timedelta(seconds=self.wait_times[i] * 2), + time_responded_to=yesterday + timezone.timedelta(seconds=1000), + status=Question.STATUS_ANSWERED, + ) + q2.time_asked = yesterday + q2.save() + + q3 = Question.objects.create( + text=f"Question {i + 2 * len(self.wait_times)}", + queue=self.queue, + asked_by=student, + responded_to_by=ta, + time_response_started=yesterday + + timezone.timedelta(seconds=self.wait_times[i] * 3), + time_responded_to=yesterday + timezone.timedelta(seconds=self.wait_times[i] * 3), + status=Question.STATUS_REJECTED, + ) + q3.time_asked = yesterday + q3.save() + + # create question that wasn't yesterday + self.old_time_wait = 789 + q4 = Question.objects.create( + text="Old question", + queue=self.queue, + asked_by=student, + responded_to_by=ta, + time_response_started=yesterday + - timezone.timedelta(days=2, seconds=-self.old_time_wait), + status=Question.STATUS_ACTIVE, + ) + q4.time_asked = yesterday - timezone.timedelta(days=2) + q4.save() + + def test_wait_time_days_computation(self): + call_command("queue_daily_stat") + expected = sum(self.wait_times) * 6 / (len(self.wait_times) * 3) + + yesterday = timezone.datetime.today().date() - timezone.timedelta(days=1) + actual = QueueStatistic.objects.get( + queue=self.queue, metric=QueueStatistic.METRIC_AVG_WAIT, date=yesterday + ).value + + self.assertEqual(expected, actual) + + call_command("queue_daily_stat", "--hist") + expected_old = self.old_time_wait + actual_old = QueueStatistic.objects.get( + queue=self.queue, + metric=QueueStatistic.METRIC_AVG_WAIT, + date=yesterday - timezone.timedelta(days=2), + ).value + + self.assertEqual(expected_old, actual_old) + + +class QueueAverageTimeHelpingTestCase(TestCase): + def setUp(self): + semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + course = Course.objects.create( + course_code="000", department="TEST", course_title="Title", semester=semester + ) + self.queue = Queue.objects.create(name="Queue", course=course) + ta = User.objects.create_user("ta", "ta@a.com", "ta") + student = User.objects.create_user("student", "student@a.com", "student") + + yesterday = timezone.localtime() - timezone.timedelta(days=1) + + self.help_times = [100, 200, 300, 400] + for i in range(len(self.help_times)): + question = Question.objects.create( + text=f"Question {i}", + queue=self.queue, + asked_by=student, + responded_to_by=ta, + time_response_started=yesterday, + time_responded_to=yesterday + timezone.timedelta(seconds=self.help_times[i]), + status=Question.STATUS_ANSWERED, + ) + question.time_asked = yesterday + question.save() + + # create question that isn't in the current week + self.old_question_time_helped = 1234 + old_question = Question.objects.create( + text="Old question", + queue=self.queue, + asked_by=student, + responded_to_by=ta, + time_response_started=yesterday - timezone.timedelta(weeks=2), + time_responded_to=yesterday + - timezone.timedelta(weeks=2) + + timezone.timedelta(seconds=self.old_question_time_helped), + status=Question.STATUS_ANSWERED, + ) + old_question.time_asked = yesterday - timezone.timedelta(weeks=2) + old_question.save() + + # create a rejected question, won't be included + rejected = Question.objects.create( + text="Withdrawn question", + queue=self.queue, + asked_by=student, + responded_to_by=ta, + time_response_started=yesterday, + time_responded_to=yesterday, + status=Question.STATUS_REJECTED, + ) + rejected.time_asked = yesterday + rejected.save() + + def test_avg_time_helping_computation(self): + call_command("queue_daily_stat") + expected = sum(self.help_times) / len(self.help_times) + + yesterday = timezone.datetime.today().date() - timezone.timedelta(days=1) + actual = QueueStatistic.objects.get( + queue=self.queue, metric=QueueStatistic.METRIC_AVG_TIME_HELPING, date=yesterday + ).value + self.assertEqual(expected, actual) + + call_command("queue_daily_stat", "--hist") + expected_old = self.old_question_time_helped + actual_old = QueueStatistic.objects.get( + queue=self.queue, + metric=QueueStatistic.METRIC_AVG_TIME_HELPING, + date=yesterday - timezone.timedelta(weeks=2), + ).value + + self.assertEqual(expected_old, actual_old) + + +class QueueNumberQuestionsAnsweredTestCase(TestCase): + def setUp(self): + semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + course = Course.objects.create( + course_code="000", department="TEST", course_title="Title", semester=semester + ) + self.queue = Queue.objects.create(name="Queue", course=course) + ta = User.objects.create_user("ta", "ta@a.com", "ta") + student = User.objects.create_user("student", "student@a.com", "student") + + yesterday = timezone.localtime() - timezone.timedelta(days=1) + self.num_questions_answered = 4 + + for i in range(self.num_questions_answered): + question = Question.objects.create( + text=f"Question {i}", + queue=self.queue, + asked_by=student, + responded_to_by=ta, + time_response_started=yesterday - timezone.timedelta(seconds=10), + time_responded_to=yesterday, + status=Question.STATUS_ANSWERED, + ) + question.time_asked = yesterday - timezone.timedelta(seconds=20) + question.save() + + # create question that isn't in the current week + old_question = Question.objects.create( + text="Old question", + queue=self.queue, + asked_by=student, + responded_to_by=ta, + time_response_started=yesterday - timezone.timedelta(weeks=2), + time_responded_to=yesterday - timezone.timedelta(weeks=1), + status=Question.STATUS_ANSWERED, + ) + old_question.time_asked = yesterday - timezone.timedelta(weeks=2) + old_question.save() + + # create an active question, won't be included + active = Question.objects.create( + text="active question", + queue=self.queue, + asked_by=student, + responded_to_by=ta, + time_response_started=yesterday, + time_responded_to=yesterday, + status=Question.STATUS_ACTIVE, + ) + active.time_asked = yesterday + active.save() + + def test_num_questions_ans_computation(self): + call_command("queue_daily_stat") + expected = self.num_questions_answered + + yesterday = timezone.datetime.today().date() - timezone.timedelta(days=1) + actual = QueueStatistic.objects.get( + queue=self.queue, metric=QueueStatistic.METRIC_NUM_ANSWERED, date=yesterday + ).value + + self.assertEqual(expected, actual) + + call_command("queue_daily_stat", "--hist") + + expected_old = 1 + actual_old = QueueStatistic.objects.get( + queue=self.queue, + metric=QueueStatistic.METRIC_NUM_ANSWERED, + date=yesterday - timezone.timedelta(weeks=1), + ).value + + self.assertEqual(expected_old, actual_old) + + +class QueueNumberStudentsHelpedTestCase(TestCase): + def setUp(self): + semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + course = Course.objects.create( + course_code="000", department="TEST", course_title="Title", semester=semester + ) + self.queue = Queue.objects.create(name="Queue", course=course) + ta = User.objects.create_user("ta", "ta@a.com", "ta") + student1 = User.objects.create_user("student1", "student1@a.com", "student1") + student2 = User.objects.create_user("student2", "student2@a.com", "student2") + student3 = User.objects.create_user("student3", "student3@a.com", "student3") + + yesterday = timezone.localtime() - timezone.timedelta(days=1) + students = [student1, student2, student3] + self.number_helped = 2 + + for i in range(self.number_helped): + question = Question.objects.create( + text=f"Question {i}", + queue=self.queue, + asked_by=students[i], + responded_to_by=ta, + time_response_started=yesterday - timezone.timedelta(seconds=10), + time_responded_to=yesterday, + status=Question.STATUS_ANSWERED, + ) + question.time_asked = yesterday - timezone.timedelta(seconds=20) + question.save() + + # create question that isn't in the current week + old_question = Question.objects.create( + text="Old question", + queue=self.queue, + asked_by=student3, + responded_to_by=ta, + time_response_started=yesterday - timezone.timedelta(weeks=2), + time_responded_to=yesterday - timezone.timedelta(weeks=1), + status=Question.STATUS_ANSWERED, + ) + old_question.time_asked = yesterday - timezone.timedelta(weeks=2) + old_question.save() + + # create an active question, won't be included + active = Question.objects.create( + text="active question", + queue=self.queue, + asked_by=student3, + responded_to_by=ta, + time_response_started=yesterday, + time_responded_to=yesterday, + status=Question.STATUS_ACTIVE, + ) + active.time_asked = yesterday + active.save() + + def test_num_students_helped_computation(self): + call_command("queue_daily_stat") + expected = self.number_helped + + yesterday = timezone.datetime.today().date() - timezone.timedelta(days=1) + actual = QueueStatistic.objects.get( + queue=self.queue, metric=QueueStatistic.METRIC_STUDENTS_HELPED, date=yesterday + ).value + + self.assertEqual(expected, actual) + + call_command("queue_daily_stat", "--hist") + expected = 1 + actual = QueueStatistic.objects.get( + queue=self.queue, + metric=QueueStatistic.METRIC_STUDENTS_HELPED, + date=yesterday - timezone.timedelta(weeks=1), + ).value + self.assertEqual(expected, actual) + + +class QueueAverageWaitHeatmapTestCase(TestCase): + def setUp(self): + semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + course = Course.objects.create( + course_code="000", department="TEST", course_title="Title", semester=semester + ) + self.queue = Queue.objects.create(name="Queue", course=course) + ta = User.objects.create_user("ta", "ta@a.com", "ta") + student = User.objects.create_user("student", "student@a.com", "student") + + yesterday = timezone.localtime() - timezone.timedelta(days=1) + + self.wait_times_9 = [100, 200, 300, 400] + for i in range(len(self.wait_times_9)): + yesterday_9 = yesterday.replace(hour=9, minute=0) + time_asked = ( + yesterday_9 + if i % 2 == 0 + else yesterday_9 + timezone.timedelta(weeks=-1, minutes=30) + ) + question = Question.objects.create( + text=f"Question {i}", + queue=self.queue, + asked_by=student, + responded_to_by=ta, + time_response_started=time_asked + timezone.timedelta(seconds=self.wait_times_9[i]), + status=Question.STATUS_ACTIVE, + ) + question.time_asked = time_asked + question.save() + + self.wait_times_17 = [500, 600, 700, 800] + for i in range(len(self.wait_times_17)): + yesterday_17 = yesterday.replace(hour=17, minute=0) + time_asked = ( + yesterday_17 + if i % 2 == 0 + else yesterday_17 + timezone.timedelta(weeks=-1, minutes=30) + ) + question = Question.objects.create( + text=f"Question {i}", + queue=self.queue, + asked_by=student, + responded_to_by=ta, + time_response_started=time_asked + + timezone.timedelta(seconds=self.wait_times_17[i]), + status=Question.STATUS_ACTIVE, + ) + question.time_asked = time_asked + question.save() + + # create question that is two days old - not included + self.older_wait_time = 321 + self.older = Question.objects.create( + text="Old question", + queue=self.queue, + asked_by=student, + responded_to_by=ta, + time_response_started=yesterday_9 + - timezone.timedelta(weeks=2, days=2, seconds=-self.older_wait_time), + status=Question.STATUS_ACTIVE, + ) + self.older.time_asked = yesterday_9 - timezone.timedelta(weeks=2, days=2) + self.older.save() + + def test_avg_queue_wait_computation(self): + call_command("queue_heatmap_stat") + yesterday = timezone.datetime.today().date() - timezone.timedelta(days=1) + yesterday_weekday = yesterday.weekday() + + expected_9 = sum(self.wait_times_9) / len(self.wait_times_9) + actual_9 = QueueStatistic.objects.get( + queue=self.queue, + metric=QueueStatistic.METRIC_HEATMAP_WAIT, + day=(yesterday_weekday + 1) % 7 + 1, + hour=9, + ).value + self.assertEqual(expected_9, actual_9) + + expected_17 = sum(self.wait_times_17) / len(self.wait_times_17) + actual_17 = QueueStatistic.objects.get( + queue=self.queue, + metric=QueueStatistic.METRIC_HEATMAP_WAIT, + day=(yesterday_weekday + 1) % 7 + 1, + hour=17, + ).value + self.assertEqual(expected_17, actual_17) + + expected_8 = 0 + actual_8 = QueueStatistic.objects.get( + queue=self.queue, + metric=QueueStatistic.METRIC_HEATMAP_WAIT, + day=(yesterday_weekday + 1) % 7 + 1, + hour=8, + ).value + self.assertEqual(expected_8, actual_8) + + call_command("queue_heatmap_stat", "--hist") + + expected_older = self.older_wait_time + actual_older = QueueStatistic.objects.get( + queue=self.queue, + metric=QueueStatistic.METRIC_HEATMAP_WAIT, + day=(self.older.time_asked.weekday() + 1) % 7 + 1, + hour=self.older.time_asked.hour, + ).value + self.assertEqual(expected_older, actual_older) + + +class QueueQuestionsPerTAHeatmapTestCase(TestCase): + def setUp(self): + semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + course = Course.objects.create( + course_code="000", department="TEST", course_title="Title", semester=semester + ) + self.queue = Queue.objects.create(name="Queue", course=course) + ta1 = User.objects.create_user("ta1", "ta1@a.com", "ta1") + ta2 = User.objects.create_user("ta2", "ta2@a.com", "ta2") + student = User.objects.create_user("student", "student@a.com", "student") + + self.num_tas_8 = 2 + self.num_tas_17 = 2 + + yesterday = timezone.localtime() - timezone.timedelta(days=1) + + self.ta_1_questions_8_week_1 = 2 + self.ta_1_questions_8_week_2 = 3 + for i in range(self.ta_1_questions_8_week_1 + self.ta_1_questions_8_week_2): + yesterday_8 = yesterday.replace(hour=8, minute=0) + time_asked = ( + yesterday_8 + if i >= self.ta_1_questions_8_week_1 + else yesterday_8 + timezone.timedelta(weeks=-1, minutes=30) + ) + question = Question.objects.create( + text=f"Question {i}", + queue=self.queue, + asked_by=student, + responded_to_by=ta1 if i % 2 == 0 else ta2, + time_response_started=time_asked + timezone.timedelta(minutes=10), + status=Question.STATUS_ACTIVE if i % 3 == 0 else Question.STATUS_ANSWERED, + ) + question.time_asked = time_asked + question.save() + + self.ta_1_questions_17 = 3 + self.ta_2_questions_17 = 4 + for i in range(self.ta_1_questions_17 + self.ta_2_questions_17): + yesterday_17 = yesterday.replace(hour=17, minute=0) + time_asked = yesterday_17 + question = Question.objects.create( + text=f"Question {i}", + queue=self.queue, + asked_by=student, + responded_to_by=ta1 if i < self.ta_1_questions_17 else ta2, + time_response_started=time_asked + timezone.timedelta(minutes=10), + status=Question.STATUS_ACTIVE if i % 3 == 0 else Question.STATUS_ANSWERED, + ) + question.time_asked = time_asked + question.save() + + # divide by zero case setup + self.questions_20 = 2 + for i in range(self.questions_20): + yesterday_20 = yesterday.replace(hour=20, minute=0) + time_asked = yesterday_20 + question = Question.objects.create( + text=f"Question {i}", + queue=self.queue, + asked_by=student, + status=Question.STATUS_ASKED, + ) + question.time_asked = time_asked + question.save() + + # create question that is two days old - not included + self.older_time_asked = yesterday_8 - timezone.timedelta(weeks=2, days=2) + self.older_time_asked.replace(hour=8, minute=0) + older = Question.objects.create( + text="Old question", + queue=self.queue, + asked_by=student, + responded_to_by=ta2, + time_response_started=self.older_time_asked + timezone.timedelta(minutes=5), + status=Question.STATUS_ACTIVE, + ) + older.time_asked = self.older_time_asked + older.save() + + def test_questions_per_ta_computation(self): + call_command("queue_heatmap_stat") + yesterday = timezone.datetime.today().date() - timezone.timedelta(days=1) + yesterday_weekday = yesterday.weekday() + + expected_8 = ( + self.ta_1_questions_8_week_1 / self.num_tas_8 + + self.ta_1_questions_8_week_2 / self.num_tas_8 + ) / 2 + actual_8 = QueueStatistic.objects.get( + queue=self.queue, + metric=QueueStatistic.METRIC_HEATMAP_QUESTIONS_PER_TA, + day=(yesterday_weekday + 1) % 7 + 1, + hour=8, + ).value + self.assertEqual(expected_8, actual_8) + + expected_17 = (self.ta_1_questions_17 + self.ta_2_questions_17) / self.num_tas_17 + actual_17 = QueueStatistic.objects.get( + queue=self.queue, + metric=QueueStatistic.METRIC_HEATMAP_QUESTIONS_PER_TA, + day=(yesterday_weekday + 1) % 7 + 1, + hour=17, + ).value + self.assertEqual(expected_17, actual_17) + + expected_20 = self.questions_20 + actual_20 = QueueStatistic.objects.get( + queue=self.queue, + metric=QueueStatistic.METRIC_HEATMAP_QUESTIONS_PER_TA, + day=(yesterday_weekday + 1) % 7 + 1, + hour=20, + ).value + self.assertEqual(expected_20, actual_20) + + call_command("queue_heatmap_stat", "--hist") + + expected_two_days_ago_8 = 1 + actual_two_days_ago_8 = QueueStatistic.objects.get( + queue=self.queue, + metric=QueueStatistic.METRIC_HEATMAP_QUESTIONS_PER_TA, + day=(self.older_time_asked.weekday() + 1) % 7 + 1, + hour=self.older_time_asked.hour, + ).value + self.assertEqual(expected_two_days_ago_8, actual_two_days_ago_8) + + +class CourseStudentMostQuestionsAskedTestCase(TestCase): + def setUp(self): + semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + course = Course.objects.create( + course_code="000", department="TEST", course_title="Title", semester=semester + ) + self.queue = Queue.objects.create(name="Queue", course=course) + ta = User.objects.create_user("ta", "ta@a.com", "ta") + student1 = User.objects.create_user("student1", "student1@a.com", "student1") + student2 = User.objects.create_user("student2", "student2@a.com", "student2") + student3 = User.objects.create_user("student3", "student3@a.com", "student3") + student4 = User.objects.create_user("student4", "student4@a.com", "student4") + student5 = User.objects.create_user("student5", "student5@a.com", "student5") + student6 = User.objects.create_user("student6", "student6@a.com", "student6") + student7 = User.objects.create_user("student7", "student7@a.com", "student7") + students = [student1, student2, student3, student4, student5, student6, student7] + + self.num_questions_per_student = { + student1: 30, + student2: 20, + student3: 10, + student4: 5, + student5: 4, + student6: 2, + student7: 50, + } + + self.question_date = timezone.datetime.today().date() - timezone.timedelta(days=1) + + # this command computes avg wait time yesterday + for student in students: + for i in range(self.num_questions_per_student[student]): + # test all varieties of statuses + q1 = Question.objects.create( + text=f"Question {i} Active", + queue=self.queue, + asked_by=student, + responded_to_by=ta, + time_response_started=self.question_date, + status=Question.STATUS_ACTIVE, + ) + q1.time_asked = self.question_date + q1.save() + + q2 = Question.objects.create( + text=f"Question {i} Answered", + queue=self.queue, + asked_by=student, + responded_to_by=ta, + time_response_started=self.question_date, + time_responded_to=self.question_date + timezone.timedelta(seconds=100), + status=Question.STATUS_ANSWERED, + ) + q2.time_asked = self.question_date + q2.save() + + q3 = Question.objects.create( + text=f"Question {i} Rejected", + queue=self.queue, + asked_by=student, + responded_to_by=ta, + time_response_started=self.question_date, + time_responded_to=self.question_date, + status=Question.STATUS_REJECTED, + ) + q3.time_asked = self.question_date + q3.save() + + # create questions that weren't in the last week + for i in range(10): + q4 = Question.objects.create( + text="Old question", + queue=self.queue, + asked_by=student1, + responded_to_by=ta, + time_response_started=self.question_date - timezone.timedelta(days=9), + time_responded_to=self.question_date + - timezone.timedelta(days=9) + + timezone.timedelta(seconds=100), + status=Question.STATUS_ANSWERED, + ) + q4.time_asked = self.question_date - timezone.timedelta(days=9, minutes=20) + q4.save() + + def test_student_most_questions_computation(self): + call_command("course_stat") + + # Top 5 students who asked to most questions + expected = { + student.pk: count * 3 + for student, count in sorted( + self.num_questions_per_student.items(), key=lambda x: -x[1] + )[:5] + } + + last_sunday = self.question_date - timezone.timedelta( + days=(self.question_date.weekday() + 1) % 7 + ) + query = CourseStatistic.objects.filter( + metric=CourseStatistic.METRIC_STUDENT_QUESTIONS_ASKED, date=last_sunday + ) + actual = {} + for ele in query: + actual[ele.user.pk] = int(ele.value) + self.assertEqual(expected, actual) + + +class CourseStudentMostTimeBeingHelpedTestCase(TestCase): + def setUp(self): + semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + course = Course.objects.create( + course_code="000", department="TEST", course_title="Title", semester=semester + ) + self.queue = Queue.objects.create(name="Queue", course=course) + ta = User.objects.create_user("ta", "ta@a.com", "ta") + student1 = User.objects.create_user("student1", "student1@a.com", "student1") + student2 = User.objects.create_user("student2", "student2@a.com", "student2") + student3 = User.objects.create_user("student3", "student3@a.com", "student3") + student4 = User.objects.create_user("student4", "student4@a.com", "student4") + student5 = User.objects.create_user("student5", "student5@a.com", "student5") + student6 = User.objects.create_user("student6", "student6@a.com", "student6") + student7 = User.objects.create_user("student7", "student7@a.com", "student7") + students = [student1, student2, student3, student4, student5, student6, student7] + + self.num_questions_per_student = { + student1: 1, + student2: 2, + student3: 3, + student4: 4, + student5: 5, + student6: 6, + student7: 7, + } + self.time_per_question_student = { + student1: 500, + student2: 200, + student3: 300, + student4: 800, + student5: 900, + student6: 300, + student7: 100, + } + + self.question_date = datetime(2021, 12, 1) + + # this command computes avg wait time yesterday + for student in students: + for i in range(self.num_questions_per_student[student]): + # test all varieties of statuses + q1 = Question.objects.create( + text=f"Question {i} Active", + queue=self.queue, + asked_by=student, + responded_to_by=ta, + time_response_started=self.question_date, + status=Question.STATUS_ACTIVE, + ) + q1.time_asked = self.question_date + q1.save() + + q2 = Question.objects.create( + text=f"Question {i} Answered", + queue=self.queue, + asked_by=student, + responded_to_by=ta, + time_response_started=self.question_date, + time_responded_to=self.question_date + + timezone.timedelta(seconds=self.time_per_question_student[student]), + status=Question.STATUS_ANSWERED, + ) + q2.time_asked = self.question_date + q2.save() + + q3 = Question.objects.create( + text=f"Question {i} Rejected", + queue=self.queue, + asked_by=student, + responded_to_by=ta, + time_response_started=self.question_date, + time_responded_to=self.question_date, + status=Question.STATUS_REJECTED, + ) + q3.time_asked = self.question_date + q3.save() + + # create questions that weren't in the last week + for i in range(10): + q4 = Question.objects.create( + text="Old question", + queue=self.queue, + asked_by=student1, + responded_to_by=ta, + time_response_started=self.question_date - timezone.timedelta(days=9), + time_responded_to=self.question_date + - timezone.timedelta(days=9) + + timezone.timedelta(seconds=100), + status=Question.STATUS_ANSWERED, + ) + q4.time_asked = self.question_date - timezone.timedelta(days=9, minutes=20) + q4.save() + + def test_student_most_time_being_helped_computation(self): + call_command("course_stat", "--hist") + + # Top 5 students who spent the most time getting help + total_time = {} + for student, question_count in self.num_questions_per_student.items(): + total_time[student] = question_count * self.time_per_question_student[student] + + expected = { + student.pk: count + for student, count in sorted(total_time.items(), key=lambda x: -x[1])[:5] + } + + last_sunday = datetime(2021, 11, 28) + query = CourseStatistic.objects.filter( + metric=CourseStatistic.METRIC_STUDENT_TIME_BEING_HELPED, date=last_sunday + ) + actual = {} + for ele in query: + actual[ele.user.pk] = int(ele.value) + self.assertEqual(expected, actual) + + +class CourseInstructorMostQuestionsAnsweredTestCase(TestCase): + def setUp(self): + semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + course = Course.objects.create( + course_code="000", department="TEST", course_title="Title", semester=semester + ) + self.queue = Queue.objects.create(name="Queue", course=course) + student = User.objects.create_user("student", "student@a.com", "student") + ta1 = User.objects.create_user("ta1", "ta1@a.com", "ta1") + ta2 = User.objects.create_user("ta2", "ta2@a.com", "ta2") + ta3 = User.objects.create_user("ta3", "ta3@a.com", "ta3") + ta4 = User.objects.create_user("ta4", "ta4@a.com", "ta4") + ta5 = User.objects.create_user("ta5", "ta5@a.com", "ta5") + ta6 = User.objects.create_user("ta6", "ta6@a.com", "ta6") + ta7 = User.objects.create_user("ta7", "ta7@a.com", "ta7") + tas = [ta1, ta2, ta3, ta4, ta5, ta6, ta7] + + self.num_questions_per_ta = {ta1: 21, ta2: 19, ta3: 15, ta4: 12, ta5: 11, ta6: 8, ta7: 50} + + self.question_date = datetime(2021, 12, 1) + + # this command computes avg wait time yesterday + for ta in tas: + for i in range(self.num_questions_per_ta[ta]): + # test all varieties of statuses + q1 = Question.objects.create( + text=f"Question {i} Active", + queue=self.queue, + asked_by=student, + responded_to_by=ta, + time_response_started=self.question_date, + status=Question.STATUS_ACTIVE, + ) + q1.time_asked = self.question_date + q1.save() + + q2 = Question.objects.create( + text=f"Question {i} Answered", + queue=self.queue, + asked_by=student, + responded_to_by=ta, + time_response_started=self.question_date, + time_responded_to=self.question_date + timezone.timedelta(seconds=100), + status=Question.STATUS_ANSWERED, + ) + q2.time_asked = self.question_date + q2.save() + + q3 = Question.objects.create( + text=f"Question {i} Rejected", + queue=self.queue, + asked_by=student, + responded_to_by=ta, + time_response_started=self.question_date, + time_responded_to=self.question_date, + status=Question.STATUS_REJECTED, + ) + q3.time_asked = self.question_date + q3.save() + + # create questions that weren't in the last week + for i in range(10): + q4 = Question.objects.create( + text="Old question", + queue=self.queue, + asked_by=student, + responded_to_by=ta1, + time_response_started=self.question_date - timezone.timedelta(days=9), + time_responded_to=self.question_date + - timezone.timedelta(days=9) + + timezone.timedelta(seconds=100), + status=Question.STATUS_ANSWERED, + ) + q4.time_asked = self.question_date - timezone.timedelta(days=9, minutes=20) + q4.save() + + def test_ta_most_questions_answered_computation(self): + call_command("course_stat", "--hist") + + last_sunday = datetime(2021, 11, 28) + # Top 5 TAs who answered the most questions + expected = { + ta.pk: count + for ta, count in sorted(self.num_questions_per_ta.items(), key=lambda x: -x[1])[:5] + } + + query = CourseStatistic.objects.filter( + metric=CourseStatistic.METRIC_INSTR_QUESTIONS_ANSWERED, date=last_sunday + ) + + actual = {} + for ele in query: + actual[ele.user.pk] = int(ele.value) + + self.assertEqual(expected, actual) + + +class CourseInstructorMostTimeHelpingTestCase(TestCase): + def setUp(self): + semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + course = Course.objects.create( + course_code="000", department="TEST", course_title="Title", semester=semester + ) + self.queue = Queue.objects.create(name="Queue", course=course) + student = User.objects.create_user("student", "student@a.com", "student") + ta1 = User.objects.create_user("ta1", "ta1@a.com", "ta1") + ta2 = User.objects.create_user("ta2", "ta2@a.com", "ta2") + ta3 = User.objects.create_user("ta3", "ta3@a.com", "ta3") + ta4 = User.objects.create_user("ta4", "ta4@a.com", "ta4") + ta5 = User.objects.create_user("ta5", "ta5@a.com", "ta5") + ta6 = User.objects.create_user("ta6", "ta6@a.com", "ta6") + ta7 = User.objects.create_user("ta7", "ta7@a.com", "ta7") + tas = [ta1, ta2, ta3, ta4, ta5, ta6, ta7] + + self.num_questions_per_ta = {ta1: 12, ta2: 8, ta3: 14, ta4: 6, ta5: 31, ta6: 2, ta7: 9} + self.time_per_question_ta = { + ta1: 1000, + ta2: 2000, + ta3: 1200, + ta4: 800, + ta5: 600, + ta6: 500, + ta7: 400, + } + + self.question_date = datetime(2021, 12, 4) + + # this command computes avg wait time yesterday + for ta in tas: + for i in range(self.num_questions_per_ta[ta]): + # test all varieties of statuses + q1 = Question.objects.create( + text=f"Question {i} Active", + queue=self.queue, + asked_by=student, + responded_to_by=ta, + time_response_started=self.question_date, + status=Question.STATUS_ACTIVE, + ) + q1.time_asked = self.question_date + q1.save() + + q2 = Question.objects.create( + text=f"Question {i} Answered", + queue=self.queue, + asked_by=student, + responded_to_by=ta, + time_response_started=self.question_date, + time_responded_to=self.question_date + + timezone.timedelta(seconds=self.time_per_question_ta[ta]), + status=Question.STATUS_ANSWERED, + ) + q2.time_asked = self.question_date + q2.save() + + q3 = Question.objects.create( + text=f"Question {i} Rejected", + queue=self.queue, + asked_by=student, + responded_to_by=ta, + time_response_started=self.question_date, + time_responded_to=self.question_date, + status=Question.STATUS_REJECTED, + ) + q3.time_asked = self.question_date + q3.save() + + def test_instructor_most_time_helping_computation(self): + call_command("course_stat", "--hist") + + # Top 5 students who spent the most time getting help + total_time = {} + for ta, question_count in self.num_questions_per_ta.items(): + total_time[ta] = question_count * self.time_per_question_ta[ta] + + expected = { + ta.pk: count for ta, count in sorted(total_time.items(), key=lambda x: -x[1])[:5] + } + + last_sunday = datetime(2021, 11, 28) + + query = CourseStatistic.objects.filter( + metric=CourseStatistic.METRIC_INSTR_TIME_ANSWERING, date=last_sunday + ) + actual = {} + for ele in query: + actual[ele.user.pk] = int(ele.value) + self.assertEqual(expected, actual) + + +class ArchiveCourseTestCase(TestCase): + def setUp(self): + sems = [ + (2020, Semester.TERM_FALL), + (2021, Semester.TERM_SPRING), + (2022, Semester.TERM_SPRING), + ] + + for year, term in sems: + sem = Semester.objects.create(year=year, term=term) + Course.objects.create( + course_code="ABC", department="DEPT", course_title="Tests", semester=sem + ) + + if year == 2020: + Course.objects.create( + course_code="DEF", department="DEPT", course_title="Tests", semester=sem + ) + + def test_archive_course(self): + out = StringIO() + self.assertEqual(len(Course.objects.filter(archived=False)), 4) + call_command("archive", Semester.TERM_FALL, 2020, stdout=out) + self.assertEqual(len(Course.objects.filter(archived=False)), 2) + call_command("archive", Semester.TERM_SPRING, 2021, stdout=out) + self.assertEqual(len(Course.objects.filter(archived=False)), 1) + lastCourse = Course.objects.filter(archived=False).first() + self.assertEqual(lastCourse.semester.year, 2022) + self.assertEqual(lastCourse.semester.term, Semester.TERM_SPRING) diff --git a/backend/tests/ohq/test_filters.py b/backend/tests/ohq/test_filters.py new file mode 100644 index 00000000..5157d436 --- /dev/null +++ b/backend/tests/ohq/test_filters.py @@ -0,0 +1,89 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from ohq.models import Course, Membership, Question, Queue, Semester + + +User = get_user_model() + + +class QuestionSearchFilterTestCase(TestCase): + def setUp(self): + self.client = APIClient() + self.semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + self.course = Course.objects.create( + course_code="000", department="Penn Labs", semester=self.semester + ) + self.professor = User.objects.create( + username="professor", first_name="Very", last_name="Helpful" + ) + self.student = User.objects.create( + username="student", first_name="Really", last_name="Confused" + ) + Membership.objects.create( + course=self.course, user=self.professor, kind=Membership.KIND_PROFESSOR + ) + Membership.objects.create( + course=self.course, user=self.student, kind=Membership.KIND_STUDENT + ) + + self.queue = Queue.objects.create(name="Queue", course=self.course) + self.question = Question.objects.create( + text="help me please, I'm so lost", + queue=self.queue, + asked_by=self.student, + responded_to_by=self.professor, + ) + self.client.force_authenticate(user=self.professor) + + def test_search_asked_by_first_name(self): + response = self.client.get( + reverse("ohq:questionsearch", args=[self.course.id]) + + "?search=" + + self.student.first_name + ) + body = response.json() + self.assertEqual(1, body["count"]) + + def test_search_asked_by_last_name(self): + response = self.client.get( + reverse("ohq:questionsearch", args=[self.course.id]) + + "?search=" + + self.student.last_name + ) + body = response.json() + self.assertEqual(1, body["count"]) + + def test_search_responded_to_by_first_name(self): + response = self.client.get( + reverse("ohq:questionsearch", args=[self.course.id]) + + "?search=" + + self.professor.first_name + ) + body = response.json() + self.assertEqual(1, body["count"]) + + def test_search_responded_to_by_last_name(self): + response = self.client.get( + reverse("ohq:questionsearch", args=[self.course.id]) + + "?search=" + + self.professor.last_name + ) + body = response.json() + self.assertEqual(1, body["count"]) + + def test_search_text(self): + response = self.client.get( + reverse("ohq:questionsearch", args=[self.course.id]) + "?search=lost" + ) + body = response.json() + self.assertEqual(1, body["count"]) + + def test_search_invalid(self): + response = self.client.get( + reverse("ohq:questionsearch", args=[self.course.id]) + "?search=labs" + ) + body = response.json() + self.assertEqual(0, body["count"]) diff --git a/backend/tests/ohq/test_invite.py b/backend/tests/ohq/test_invite.py new file mode 100644 index 00000000..7adc702f --- /dev/null +++ b/backend/tests/ohq/test_invite.py @@ -0,0 +1,72 @@ +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.test import TestCase + +from ohq.invite import parse_and_send_invites +from ohq.models import Course, Membership, MembershipInvite, Semester + + +User = get_user_model() + + +class ParseAndSendInvitesTestCase(TestCase): + def setUp(self): + self.professor = User.objects.create(username="professor", email="professor@seas.upenn.edu") + self.semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + self.course = Course.objects.create( + course_code="000", department="TEST", course_title="Title", semester=self.semester + ) + Membership.objects.create( + course=self.course, user=self.professor, kind=Membership.KIND_PROFESSOR + ) + self.user2 = User.objects.create(username="user2", email="user2@sas.upenn.edu") + + MembershipInvite.objects.create(course=self.course, email="user3@wharton.upenn.edu") + + def test_invalid_email(self): + with self.assertRaises(ValidationError): + parse_and_send_invites(self.course, ["not an email"], Membership.KIND_TA) + + def test_valid_emails(self): + """ + Test situations where + * the user is already a member under a different email + * the user is not a member of the course and has different email + * the email has already been sent an invite + """ + members_added, invites_sent = parse_and_send_invites( + self.course, + [ + "professor@sas.upenn.edu", + "user2@seas.upenn.edu", + "user3@wharton.upenn.edu", + "user4@nursing.upenn.edu", + ], + Membership.KIND_TA, + ) + + # # Correct number of invites and memberships created + self.assertEqual(1, members_added) + self.assertEqual(1, invites_sent) + + # Membership is created for user2 + self.assertEqual( + 1, + Membership.objects.filter( + user=self.user2, course=self.course, kind=Membership.KIND_TA + ).count(), + ) + + # Duplicate membership for user 1 isn't created + self.assertEqual(2, Membership.objects.all().count()) + + # Invite is sent to 4@nursing.upenn.edu + self.assertEqual( + 1, + MembershipInvite.objects.filter( + email="user4@nursing.upenn.edu", course=self.course, kind=Membership.KIND_TA + ).count(), + ) + + # Duplicate membership invite for 3@example.com isn't created + self.assertEqual(2, MembershipInvite.objects.all().count()) diff --git a/backend/tests/ohq/test_live.py b/backend/tests/ohq/test_live.py new file mode 100644 index 00000000..22ed9b31 --- /dev/null +++ b/backend/tests/ohq/test_live.py @@ -0,0 +1,218 @@ +import json + +from asgiref.sync import sync_to_async +from channels.auth import AuthMiddlewareStack +from channels.db import database_sync_to_async as db +from django.contrib.auth import get_user_model +from django.test import TransactionTestCase +from django.urls import reverse +from djangorestframework_camel_case.util import camelize +from rest_framework.test import APIClient +from rest_live.testing import APICommunicator, async_test, get_headers_for_user + +from ohq.models import Announcement, Course, Membership, Question, Queue, Semester +from ohq.serializers import AnnouncementSerializer +from ohq.urls import realtime_router + + +User = get_user_model() + + +class QuestionTestCase(TransactionTestCase): + async def asyncSetUp(self): + self.semester = await db(Semester.objects.create)(year=2020, term=Semester.TERM_SUMMER) + self.course = await db(Course.objects.create)( + course_code="000", department="TEST", course_title="Title", semester=self.semester + ) + self.queue = await db(Queue.objects.create)(name="Queue", course=self.course) + self.professor = await db(User.objects.create)(username="professor") + self.professor_membership = await db(Membership.objects.create)( + course=self.course, user=self.professor, kind=Membership.KIND_PROFESSOR + ) + self.student1 = await db(User.objects.create)(username="student1") + await db(Membership.objects.create)( + course=self.course, user=self.student1, kind=Membership.KIND_STUDENT + ) + self.student2 = await db(User.objects.create)(username="student2") + await db(Membership.objects.create)( + course=self.course, user=self.student2, kind=Membership.KIND_STUDENT + ) + self.old_question = await db(Question.objects.create)( + queue=self.queue, asked_by=self.student1, text="Help me", + ) + + headers = await get_headers_for_user(self.student2) + self.client = APICommunicator( + AuthMiddlewareStack(realtime_router.as_consumer()), "/api/ws/subscribe/", headers + ) + connected, _ = await self.client.connect() + self.assertTrue(connected) + + self.api_client = APIClient() + + async def asyncTearDown(self): + await self.client.disconnect() + + @async_test + async def testPositionUpdate(self): + list_payload = { + "type": "subscribe", + "id": 1, + "action": "list", + "model": "ohq.Question", + "view_kwargs": {"course_pk": self.course.id, "queue_pk": self.queue.id}, + } + + await self.client.send_json_to(list_payload) + self.assertTrue(await self.client.receive_nothing()) + + await sync_to_async(self.api_client.force_authenticate)(user=self.student2) + new_question = await sync_to_async(self.api_client.post)( + reverse("ohq:question-list", args=[self.course.id, self.queue.id]), + {"text": "New question", "tags": []}, + ) + + question_position = 2 + response = await self.client.receive_json_from() + + self.assertEquals(question_position, response["instance"]["position"]) + + retrieve_payload = { + "type": "subscribe", + "id": 1, + "action": "retrieve", + "model": "ohq.Question", + "lookup_by": json.loads(new_question.content)["id"], + "view_kwargs": {"course_pk": self.course.id, "queue_pk": self.queue.id}, + } + + await self.client.send_json_to(retrieve_payload) + self.assertTrue(await self.client.receive_nothing()) + + await sync_to_async(self.api_client.force_authenticate)(user=self.professor) + await sync_to_async(self.api_client.patch)( + reverse( + "ohq:question-detail", args=[self.course.id, self.queue.id, self.old_question.id] + ), + {"status": Question.STATUS_ACTIVE}, + ) + question_position -= 1 + + response = await self.client.receive_json_from() + self.assertEquals(question_position, response["instance"]["position"]) + + +class AnnouncementTestCase(TransactionTestCase): + async def asyncSetUp(self): + self.semester = await db(Semester.objects.create)(year=2020, term=Semester.TERM_SUMMER) + self.course = await db(Course.objects.create)( + course_code="000", department="TEST", course_title="Title", semester=self.semester + ) + self.professor = await db(User.objects.create)(username="professor") + await db(Membership.objects.create)( + course=self.course, user=self.professor, kind=Membership.KIND_PROFESSOR + ) + self.student = await db(User.objects.create)(username="student") + await db(Membership.objects.create)( + course=self.course, user=self.student, kind=Membership.KIND_STUDENT + ) + + headers = await get_headers_for_user(self.student) + self.client = APICommunicator( + AuthMiddlewareStack(realtime_router.as_consumer()), "/api/ws/subscribe/", headers + ) + connected, _ = await self.client.connect() + self.assertTrue(connected) + + async def asyncTearDown(self): + await self.client.disconnect() + + @async_test + async def test_subscribe_list(self): + payload = { + "type": "subscribe", + "id": 1, + "action": "list", + "model": "ohq.Announcement", + "view_kwargs": {"course_pk": self.course.id}, + } + await self.client.send_json_to(payload) + self.assertTrue(await self.client.receive_nothing()) + + new_announcement = await db(Announcement.objects.create)( + course=self.course, author=self.professor, content="New announcement" + ) + response = await self.client.receive_json_from() + expected = { + "type": "broadcast", + "id": 1, + "model": "ohq.Announcement", + "instance": camelize(AnnouncementSerializer(new_announcement).data), + "action": "CREATED", + } + self.assertEqual(expected, response) + + new_announcement.content = "Updated content" + await db(new_announcement.save)() + response = await self.client.receive_json_from() + expected = { + "type": "broadcast", + "id": 1, + "model": "ohq.Announcement", + "instance": camelize(AnnouncementSerializer(new_announcement).data), + "action": "UPDATED", + } + self.assertEqual(expected, response) + + annoucement_id = new_announcement.id + await db(new_announcement.delete)() + response = await self.client.receive_json_from() + expected = { + "type": "broadcast", + "id": 1, + "model": "ohq.Announcement", + "instance": {"pk": annoucement_id, "id": annoucement_id}, + "action": "DELETED", + } + self.assertEqual(expected, response) + + @async_test + async def test_subscribe_single(self): + new_announcement = await db(Announcement.objects.create)( + course=self.course, author=self.professor, content="New announcement" + ) + + payload = { + "type": "subscribe", + "id": 2, + "action": "retrieve", + "model": "ohq.Announcement", + "view_kwargs": {"course_pk": self.course.id}, + "lookup_by": new_announcement.id, + } + await self.client.send_json_to(payload) + self.assertTrue(await self.client.receive_nothing()) + + new_announcement.content = "Updated content" + await db(new_announcement.save)() + response = await self.client.receive_json_from() + expected = { + "type": "broadcast", + "id": 2, + "model": "ohq.Announcement", + "instance": camelize(AnnouncementSerializer(new_announcement).data), + "action": "UPDATED", + } + self.assertEqual(expected, response) + + annoucement_id = new_announcement.id + await db(new_announcement.delete)() + response = await self.client.receive_json_from() + expected = { + "type": "broadcast", + "id": 2, + "model": "ohq.Announcement", + "instance": {"pk": annoucement_id, "id": annoucement_id}, + "action": "DELETED", + } + self.assertEqual(expected, response) diff --git a/backend/tests/ohq/test_migrations.py b/backend/tests/ohq/test_migrations.py new file mode 100644 index 00000000..3c9c06c2 --- /dev/null +++ b/backend/tests/ohq/test_migrations.py @@ -0,0 +1,85 @@ +from django.db import connection +from django.db.migrations.executor import MigrationExecutor +from django.test import TransactionTestCase + +from ohq.models import Semester + + +class MigrationTest(TransactionTestCase): + """ + Test applying a new migration on top of an old migration. + Make sure that `self.migrate_from` and `self.migrate_to` are defined, and also implement + `setUpBeforeMigration()` to setup the model before migrations are applied. + """ + + migrate_from = None # need to be defined by subclasses + migrate_to = None # need to be defined by subclasses + + @property + def app(self): + return "ohq" + + def setUp(self): + super().setUp() + assert ( + self.migrate_to and self.migrate_from + ), f"TestCase {type(self).__name} must define migrate_to and migrate_from properties" + + self.migrate_from = [(self.app, self.migrate_from)] + self.migrate_to = [(self.app, self.migrate_to)] + self.executor = MigrationExecutor(connection) + self.pre_migration = self.executor.loader.project_state(self.migrate_from).apps + + # revert to the original migration + self.executor.migrate(self.migrate_from) + + # ensure return to the latest migration, even if the test fails + self.addCleanup(self.force_migrate) + + # perform final migration setup + self.setUpBeforeMigration(self.pre_migration) + + # Finally apply the migration + self.executor.loader.build_graph() + self.executor.migrate(self.migrate_to) + self.post_migration = self.executor.loader.project_state(self.migrate_to).apps + + # Implement in subclasses to setup models before applying a migration + def setUpBeforeMigration(self, apps): + pass + + # forces a migration to the latest migration even if tests fail + def force_migrate(self, migrate_to=None): + self.executor.loader.build_graph() # reload. + if migrate_to is None: + # get latest migration of current app + migrate_to = [ + key for key in self.executor.loader.graph.leaf_nodes() if key[0] == self.app + ] + + self.executor.migrate(migrate_to) + + +class TestQuestionTemplatesMigration(MigrationTest): + migrate_from = "0014_question_student_descriptor" + migrate_to = "0015_question_templates" + + def setUpBeforeMigration(self, pre_migration): + self.semester = pre_migration.get_model(self.app, "Semester").objects.create( + year=2020, term=Semester.TERM_SUMMER + ) + self.course = pre_migration.get_model(self.app, "Course").objects.create( + course_code="000", department="Penn Labs", semester=self.semester + ) + self.queue = pre_migration.get_model(self.app, "Queue").objects.create( + name="Queue", course=self.course + ) + + def test_question_templates_migrated(self): + queue = self.post_migration.get_model(self.app, "Queue").objects.get(id=self.queue.id) + + # The queue created before migration now has a question_template attribute + self.assertIs(hasattr(queue, "question_template"), True) + + # The question_template is "" by default + self.assertIs(queue.question_template, "") diff --git a/backend/tests/ohq/test_models.py b/backend/tests/ohq/test_models.py new file mode 100644 index 00000000..360210ff --- /dev/null +++ b/backend/tests/ohq/test_models.py @@ -0,0 +1,309 @@ +from django.contrib.auth import get_user_model +from django.core import mail +from django.test import TestCase +from django.utils import timezone + +from ohq.models import ( + Course, + CourseStatistic, + Membership, + MembershipInvite, + Question, + Queue, + QueueStatistic, + Semester, + Tag, +) + + +User = get_user_model() + + +class ProfileTestCase(TestCase): + def setUp(self): + self.user = User.objects.create(username="user") + + def test_str(self): + self.assertEqual(str(self.user.profile), str(self.user)) + + +class TagTestCase(TestCase): + def setUp(self): + self.semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + self.course = Course.objects.create( + course_code="000", department="TEST", course_title="Title", semester=self.semester + ) + self.tag = Tag.objects.create(name="Test Tag", course=self.course) + + def test_str(self): + self.assertEqual(str(self.tag), f"{self.course}: Test Tag") + + +class CourseTestCase(TestCase): + def setUp(self): + self.semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + self.course = Course.objects.create( + course_code="000", department="TEST", course_title="Title", semester=self.semester + ) + + def test_str(self): + self.assertEqual( + str(self.course), + f"{self.course.department} {self.course.course_code}: {self.course.semester}", + ) + + +class MembershipTestCase(TestCase): + def setUp(self): + semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + course = Course.objects.create( + course_code="000", department="TEST", course_title="Title", semester=semester + ) + self.user = User.objects.create(username="user", email="example@example.com") + self.membership = Membership.objects.create( + course=course, user=self.user, kind=Membership.KIND_PROFESSOR + ) + + def test_kind_to_pretty(self): + self.assertEqual(self.membership.kind_to_pretty(), "Professor") + + def test_is_leadership(self): + self.assertTrue(self.membership.is_leadership) + + def test_is_ta(self): + self.assertTrue(self.membership.is_ta) + + def test_str(self): + mem = self.membership + self.assertEqual( + str(mem), f"" + ) + + def test_send_email(self): + self.membership.send_email() + self.assertEqual(1, len(mail.outbox)) + email = mail.outbox[0] + self.assertEqual(self.user.email, email.to[0]) + + +class MembershipInviteTestCase(TestCase): + def setUp(self): + semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + course = Course.objects.create( + course_code="000", department="TEST", course_title="Title", semester=semester + ) + self.invite = MembershipInvite.objects.create( + course=course, email="me@example.com", kind=Membership.KIND_PROFESSOR + ) + + def test_kind_to_pretty(self): + self.assertEqual(self.invite.kind_to_pretty(), "Professor") + + def test_str(self): + inv = self.invite + self.assertEqual( + str(inv), f"" + ) + + def test_send_email(self): + self.invite.send_email() + self.assertEqual(1, len(mail.outbox)) + email = mail.outbox[0] + self.assertEqual(self.invite.email, email.to[0]) + + +class QueueTestCase(TestCase): + def setUp(self): + semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + course = Course.objects.create( + course_code="000", department="TEST", course_title="Title", semester=semester + ) + self.queue = Queue.objects.create(name="Queue", course=course) + + def test_str(self): + self.assertEqual(str(self.queue), f"{self.queue.course}: {self.queue.name}") + + +class QuestionTestCase(TestCase): + def setUp(self): + semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + course = Course.objects.create( + course_code="000", department="TEST", course_title="Title", semester=semester + ) + queue = Queue.objects.create(name="Queue", course=course) + user = User.objects.create(username="user", email="example@example.com") + self.question = Question.objects.create(text="Question?", queue=queue, asked_by=user) + + def test_str(self): + # TODO: write this + pass + + +class SemesterTestCase(TestCase): + def setUp(self): + self.semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + + def test_term_to_pretty(self): + self.assertEqual(self.semester.term_to_pretty(), self.semester.term.title()) + + def test_str(self): + self.assertEqual(str(self.semester), f"{self.semester.term.title()} {self.semester.year}") + + +class CourseStatisticTestCase(TestCase): + def setUp(self): + semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + self.course = Course.objects.create( + course_code="000", department="TEST", course_title="Title", semester=semester + ) + self.user = User.objects.create_user("user", "user@a.com", "user") + today = timezone.datetime.today().date() + + self.student_num_questions_asked = CourseStatistic.objects.create( + course=self.course, + user=self.user, + metric=CourseStatistic.METRIC_STUDENT_QUESTIONS_ASKED, + value=100.00, + date=today, + ) + + self.student_time_being_helped = CourseStatistic.objects.create( + course=self.course, + user=self.user, + metric=CourseStatistic.METRIC_STUDENT_TIME_BEING_HELPED, + value=100.00, + date=today, + ) + + self.instructor_num_questions_answered = CourseStatistic.objects.create( + course=self.course, + user=self.user, + metric=CourseStatistic.METRIC_INSTR_QUESTIONS_ANSWERED, + value=100.00, + date=today, + ) + + self.instructor_time_answering_questions = CourseStatistic.objects.create( + course=self.course, + user=self.user, + metric=CourseStatistic.METRIC_INSTR_TIME_ANSWERING, + value=100.00, + date=today, + ) + + def test_metric_to_pretty(self): + + self.assertEqual( + self.student_num_questions_asked.metric_to_pretty(), "Student: Questions asked" + ) + + self.assertEqual( + self.student_time_being_helped.metric_to_pretty(), "Student: Time being helped", + ) + + self.assertEqual( + self.instructor_num_questions_answered.metric_to_pretty(), + "Instructor: Questions answered", + ) + + self.assertEqual( + self.instructor_time_answering_questions.metric_to_pretty(), + "Instructor: Time answering questions", + ) + + def test_str(self): + + today = timezone.datetime.today().date() + self.assertEqual( + str(self.student_num_questions_asked), + f"{self.course}: {today}: Student: Questions asked", + ) + + self.assertEqual( + str(self.student_time_being_helped), + f"{self.course}: {today}: Student: Time being helped", + ) + + self.assertEqual( + str(self.instructor_num_questions_answered), + f"{self.course}: {today}: Instructor: Questions answered", + ) + + self.assertEqual( + str(self.instructor_time_answering_questions), + f"{self.course}: {today}: Instructor: Time answering questions", + ) + + +class QueueStatisticTestCase(TestCase): + def setUp(self): + semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + course = Course.objects.create( + course_code="000", department="TEST", course_title="Title", semester=semester + ) + self.queue = Queue.objects.create(name="Queue", course=course) + + self.heatmap_queue_statistic = QueueStatistic.objects.create( + queue=self.queue, + metric=QueueStatistic.METRIC_HEATMAP_WAIT, + value=100.00, + day=QueueStatistic.DAY_MONDAY, + hour=0, + ) + today = timezone.datetime.today().date() + + self.avg_wait_time_queue_statistic = QueueStatistic.objects.create( + queue=self.queue, + metric=QueueStatistic.METRIC_AVG_WAIT, + value=150.00, + date=today - timezone.timedelta(days=1), + ) + + self.num_questions_answered_queue_statistic = QueueStatistic.objects.create( + queue=self.queue, + metric=QueueStatistic.METRIC_NUM_ANSWERED, + value=10.00, + date=today - timezone.timedelta(days=1), + ) + + self.num_students_helped_queue_statistic = QueueStatistic.objects.create( + queue=self.queue, + metric=QueueStatistic.METRIC_STUDENTS_HELPED, + value=50.00, + date=today - timezone.timedelta(days=1), + ) + + self.avg_time_helping_queue_statistic = QueueStatistic.objects.create( + queue=self.queue, + metric=QueueStatistic.METRIC_AVG_TIME_HELPING, + value=120.00, + date=today - timezone.timedelta(days=1), + ) + + def test_str(self): + self.assertEqual( + str(self.heatmap_queue_statistic), + f"{self.queue}: {self.heatmap_queue_statistic.get_metric_display()} " + f"{self.heatmap_queue_statistic.get_day_display()} " + f"{self.heatmap_queue_statistic.hour}:00 - {self.heatmap_queue_statistic.hour+1}:00", + ) + + self.assertEqual( + str(self.avg_wait_time_queue_statistic), + f"{self.queue}: {self.avg_wait_time_queue_statistic.get_metric_display()}", + ) + + self.assertEqual( + str(self.num_questions_answered_queue_statistic), + f"{self.queue}: {self.num_questions_answered_queue_statistic.get_metric_display()}", + ) + + self.assertEqual( + str(self.num_students_helped_queue_statistic), + f"{self.queue}: {self.num_students_helped_queue_statistic.get_metric_display()}", + ) + + self.assertEqual( + str(self.avg_time_helping_queue_statistic), + f"{self.queue}: {self.avg_time_helping_queue_statistic.get_metric_display()}", + ) diff --git a/backend/tests/ohq/test_permissions.py b/backend/tests/ohq/test_permissions.py new file mode 100644 index 00000000..9d4667c6 --- /dev/null +++ b/backend/tests/ohq/test_permissions.py @@ -0,0 +1,1312 @@ +from datetime import datetime +from unittest.mock import patch + +import pytz +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from parameterized import parameterized +from rest_framework.test import APIClient +from schedule.models import Calendar, Event, EventRelationManager + +from ohq.models import ( + Announcement, + Course, + Membership, + MembershipInvite, + Question, + Queue, + Semester, + Tag, +) + + +User = get_user_model() + +users = ["professor", "head_ta", "ta", "student", "non_member", "anonymous"] + + +def get_test_name(testcase_func, _, param): + """ + A function to create test names for parameterized + For example, a test case named test_list will generate names + of the form test_list_professor, test_list_head_ta, etc. + """ + + return f"{testcase_func.__name__}_{param.args[0]}" + + +def setUp(self): + """ + General set up used in each test case. + """ + + self.client = APIClient() + self.semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + self.course = Course.objects.create( + course_code="000", department="Penn Labs", semester=self.semester + ) + + # Create users + self.professor = User.objects.create(username="professor") + self.head_ta = User.objects.create(username="head_ta") + self.ta = User.objects.create(username="ta") + self.student = User.objects.create(username="student") + self.non_member = User.objects.create(username="non_member") + self.anonymous = None + + # Create Memberships + Membership.objects.create( + course=self.course, user=self.professor, kind=Membership.KIND_PROFESSOR + ) + Membership.objects.create(course=self.course, user=self.head_ta, kind=Membership.KIND_HEAD_TA) + Membership.objects.create(course=self.course, user=self.ta, kind=Membership.KIND_TA) + Membership.objects.create(course=self.course, user=self.student, kind=Membership.KIND_STUDENT) + + +def test(self, user, action_name, request, url, data=None): + """ + A helper function to reduce the duplicated code in each test case + """ + + self.client.force_authenticate(user=getattr(self, user)) + response = getattr(self.client, request)(url, data) + self.client.force_authenticate(user=None) + self.assertEqual(self.expected[action_name][user], response.status_code) + + +class CourseTestCase(TestCase): + def setUp(self): + setUp(self) + + # Expected results + self.expected = { + "list": { + "professor": 200, + "head_ta": 200, + "ta": 200, + "student": 200, + "non_member": 200, + "anonymous": 403, + }, + "create": { + "professor": 201, + "head_ta": 201, + "ta": 403, + "student": 403, + "non_member": 403, + "anonymous": 403, + }, + "retrieve": { + "professor": 200, + "head_ta": 200, + "ta": 200, + "student": 200, + "non_member": 200, + "anonymous": 403, + }, + "destroy": { + "professor": 403, + "head_ta": 403, + "ta": 403, + "student": 403, + "non_member": 403, + "anonymous": 403, + }, + "modify": { + "professor": 200, + "head_ta": 200, + "ta": 403, + "student": 403, + "non_member": 403, + "anonymous": 403, + }, + "modify-archived": { + "professor": 403, + "head_ta": 403, + "ta": 403, + "student": 403, + "non_member": 403, + "anonymous": 403, + }, + } + + @parameterized.expand(users, name_func=get_test_name) + def test_list(self, user): + test(self, user, "list", "get", reverse("ohq:course-list")) + + @parameterized.expand(users, name_func=get_test_name) + def test_create(self, user): + test( + self, + user, + "create", + "post", + reverse("ohq:course-list"), + { + "course_code": "000", + "department": "TEST", + "course_title": "Course", + "semester": self.semester.id, + "created_role": user.upper(), + }, + ) + + @parameterized.expand(users, name_func=get_test_name) + def test_retrieve(self, user): + test(self, user, "retrieve", "get", reverse("ohq:course-detail", args=[self.course.id])) + + @parameterized.expand(users, name_func=get_test_name) + def test_destroy(self, user): + test(self, user, "destroy", "delete", reverse("ohq:course-detail", args=[self.course.id])) + + @parameterized.expand(users, name_func=get_test_name) + def test_modify(self, user): + test( + self, + user, + "modify", + "patch", + reverse("ohq:course-detail", args=[self.course.id]), + {"description": "new"}, + ) + + @parameterized.expand(users, name_func=get_test_name) + def test_modify_archived(self, user): + """ + Ensure no one can modify an archived course. + """ + + self.course.archived = True + self.course.save() + test( + self, + user, + "modify-archived", + "patch", + reverse("ohq:course-detail", args=[self.course.id]), + {"description": "new"}, + ) + + +class QueueTestCase(TestCase): + def setUp(self): + setUp(self) + self.queue = Queue.objects.create(name="Queue", course=self.course) + + # Expected results + self.expected = { + "list": { + "professor": 200, + "head_ta": 200, + "ta": 200, + "student": 200, + "non_member": 403, + "anonymous": 403, + }, + "create": { + "professor": 201, + "head_ta": 201, + "ta": 403, + "student": 403, + "non_member": 403, + "anonymous": 403, + }, + "retrieve": { + "professor": 200, + "head_ta": 200, + "ta": 200, + "student": 200, + "non_member": 403, + "anonymous": 403, + }, + "destroy": { + "professor": 204, + "head_ta": 204, + "ta": 403, + "student": 403, + "non_member": 403, + "anonymous": 403, + }, + "modify": { + "professor": 200, + "head_ta": 200, + "ta": 200, + "student": 403, + "non_member": 403, + "anonymous": 403, + }, + "clear": { + "professor": 200, + "head_ta": 200, + "ta": 200, + "student": 403, + "non_member": 403, + "anonymous": 403, + }, + } + + @parameterized.expand(users, name_func=get_test_name) + def test_list(self, user): + test(self, user, "list", "get", reverse("ohq:queue-list", args=[self.course.id])) + + @parameterized.expand(users, name_func=get_test_name) + def test_create(self, user): + test( + self, + user, + "create", + "post", + reverse("ohq:queue-list", args=[self.course.id]), + {"name": "new", "description": "description"}, + ) + + @parameterized.expand(users, name_func=get_test_name) + def test_retrieve(self, user): + test( + self, + user, + "retrieve", + "get", + reverse("ohq:queue-detail", args=[self.course.id, self.queue.id]), + ) + + @parameterized.expand(users, name_func=get_test_name) + def test_destroy(self, user): + test( + self, + user, + "destroy", + "delete", + reverse("ohq:queue-detail", args=[self.course.id, self.queue.id]), + ) + + @parameterized.expand(users, name_func=get_test_name) + def test_modify(self, user): + test( + self, + user, + "modify", + "patch", + reverse("ohq:queue-detail", args=[self.course.id, self.queue.id]), + {"active": True}, + ) + + @parameterized.expand(users, name_func=get_test_name) + def test_clear(self, user): + """ + Test who can clear questions from a queue. + """ + + test( + self, + user, + "clear", + "post", + reverse("ohq:queue-clear", args=[self.course.id, self.queue.id]), + ) + + +class TagTestCase(TestCase): + def setUp(self): + setUp(self) + self.tag = Tag.objects.create(name="test", course=self.course) + + # Expected results + self.expected = { + "list": { + "professor": 200, + "head_ta": 200, + "ta": 200, + "student": 200, + "non_member": 403, + "anonymous": 403, + }, + "create": { + "professor": 201, + "head_ta": 201, + "ta": 403, + "student": 403, + "non_member": 403, + "anonymous": 403, + }, + "retrieve": { + "professor": 200, + "head_ta": 200, + "ta": 200, + "student": 200, + "non_member": 403, + "anonymous": 403, + }, + "destroy": { + "professor": 204, + "head_ta": 204, + "ta": 403, + "student": 403, + "non_member": 403, + "anonymous": 403, + }, + "modify": { + "professor": 200, + "head_ta": 200, + "ta": 403, + "student": 403, + "non_member": 403, + "anonymous": 403, + }, + } + + @parameterized.expand(users, name_func=get_test_name) + def test_list(self, user): + test(self, user, "list", "get", reverse("ohq:tag-list", args=[self.course.id])) + + @parameterized.expand(users, name_func=get_test_name) + def test_create(self, user): + test( + self, + user, + "create", + "post", + reverse("ohq:tag-list", args=[self.course.id]), + {"name": "new", "description": "description"}, + ) + + @parameterized.expand(users, name_func=get_test_name) + def test_retrieve(self, user): + test( + self, + user, + "retrieve", + "get", + reverse("ohq:tag-detail", args=[self.course.id, self.tag.id]), + ) + + @parameterized.expand(users, name_func=get_test_name) + def test_destroy(self, user): + test( + self, + user, + "destroy", + "delete", + reverse("ohq:tag-detail", args=[self.course.id, self.tag.id]), + ) + + @parameterized.expand(users, name_func=get_test_name) + def test_modify(self, user): + test( + self, + user, + "modify", + "patch", + reverse("ohq:tag-detail", args=[self.course.id, self.tag.id]), + {"name": "test2"}, + ) + + +class QuestionTestCase(TestCase): + def setUp(self): + setUp(self) + self.queue = Queue.objects.create(name="Queue", course=self.course) + self.rate_limit_queue = Queue.objects.create( + name="Rate Limit Queue", + course=self.course, + rate_limit_enabled=True, + rate_limit_length=2, + rate_limit_minutes=10, + rate_limit_questions=2, + ) + self.question = Question.objects.create(queue=self.queue, asked_by=self.student) + self.other_question = Question.objects.create(queue=self.queue, asked_by=self.ta) + self.tag = Tag.objects.create(name="existing-tag", course=self.course) + + # Expected results + self.expected = { + "list": { + "professor": 200, + "head_ta": 200, + "ta": 200, + "student": 200, + "non_member": 403, + "anonymous": 403, + }, + "last": { + "professor": 403, + "head_ta": 403, + "ta": 403, + "student": 200, + "non_member": 403, + "anonymous": 403, + }, + "quota-count": { + "professor": 403, + "head_ta": 403, + "ta": 403, + "student": 200, + "non_member": 403, + "anonymous": 403, + }, + "create": { + "professor": 403, + "head_ta": 403, + "ta": 403, + "student": 201, + "non_member": 403, + "anonymous": 403, + }, + "create-existing": {"student": 403}, + "create-tag-existing": {"student": 201}, + "create-tag-new": {"student": 201}, + "retrieve": { + "professor": 200, + "head_ta": 200, + "ta": 200, + "student": 200, + "non_member": 403, + "anonymous": 403, + }, + "position": { + "professor": 200, + "head_ta": 200, + "ta": 200, + "student": 200, + "non_member": 403, + "anonymous": 403, + }, + "retrieve-other": {"student": 404}, + "position-other": {"student": 404}, + "destroy": { + "professor": 403, + "head_ta": 403, + "ta": 403, + "student": 403, + "non_member": 403, + "anonymous": 403, + }, + "modify": { + "professor": 200, + "head_ta": 200, + "ta": 200, + "student": 200, + "non_member": 403, + "anonymous": 403, + }, + "modify-tag-existing": {"student": 200}, + "modify-tag-new": {"student": 200}, + } + + @parameterized.expand(users, name_func=get_test_name) + def test_list(self, user): + test( + self, + user, + "list", + "get", + reverse("ohq:question-list", args=[self.course.id, self.queue.id]), + ) + + @parameterized.expand(users, name_func=get_test_name) + def test_last(self, user): + test( + self, + user, + "last", + "get", + reverse("ohq:question-last", args=[self.course.id, self.queue.id]), + ) + + @parameterized.expand(users, name_func=get_test_name) + def test_quota_count(self, user): + test( + self, + user, + "quota-count", + "get", + reverse("ohq:question-quota-count", args=[self.course.id, self.rate_limit_queue.id]), + ) + + @parameterized.expand(users, name_func=get_test_name) + def test_create(self, user): + self.question.status = Question.STATUS_ANSWERED + self.question.save() + test( + self, + user, + "create", + "post", + reverse("ohq:question-list", args=[self.course.id, self.queue.id]), + {"text": "question", "tags": []}, + ) + + def test_create_student_existing(self): + """ + Ensure a student can't submit multiple questions to a queue. + """ + + test( + self, + "student", + "create-existing", + "post", + reverse("ohq:question-list", args=[self.course.id, self.queue.id]), + {"text": "question"}, + ) + + @parameterized.expand(users, name_func=get_test_name) + def test_retrieve(self, user): + test( + self, + user, + "retrieve", + "get", + reverse("ohq:question-detail", args=[self.course.id, self.queue.id, self.question.id]), + ) + + def test_retrieve_student_other_question(self): + """ + Ensure a student can't access see anyone else's questions. + """ + + test( + self, + "student", + "retrieve-other", + "get", + reverse( + "ohq:question-detail", args=[self.course.id, self.queue.id, self.other_question.id] + ), + ) + + @parameterized.expand(users, name_func=get_test_name) + def test_position(self, user): + test( + self, + user, + "position", + "get", + reverse( + "ohq:question-position", args=[self.course.id, self.queue.id, self.question.id] + ), + ) + + def test_position_student_other_question(self): + """ + Ensure a student can't access see anyone else's question position. + """ + + test( + self, + "student", + "position-other", + "get", + reverse( + "ohq:question-position", + args=[self.course.id, self.queue.id, self.other_question.id], + ), + ) + + @parameterized.expand(users, name_func=get_test_name) + def test_destroy(self, user): + test( + self, + user, + "destroy", + "delete", + reverse("ohq:question-detail", args=[self.course.id, self.queue.id, self.question.id]), + ) + + @parameterized.expand(users, name_func=get_test_name) + @patch("ohq.serializers.sendUpNextNotificationTask.delay") + def test_modify(self, user, mock_delay): + status = Question.STATUS_WITHDRAWN if user == "student" else Question.STATUS_ACTIVE + test( + self, + user, + "modify", + "patch", + reverse("ohq:question-detail", args=[self.course.id, self.queue.id, self.question.id]), + {"status": status}, + ) + if user == "student": + mock_delay.assert_called() + else: + mock_delay.assert_not_called() + + def test_create_existing_tag(self): + """ + Ensure a student can create a question with existing tags. + """ + self.question.delete() + test( + self, + "student", + "create-tag-existing", + "post", + reverse("ohq:question-list", args=[self.course.id, self.queue.id]), + {"text": "question", "tags": [{"name": "existing-tag"}]}, + ) + + def test_create_new_tag(self): + """ + Ensure a student can not create a question with new tags. + """ + + self.question.delete() + test( + self, + "student", + "create-tag-new", + "post", + reverse("ohq:question-list", args=[self.course.id, self.queue.id]), + {"text": "question", "tags": [{"name": "new-tag"}]}, + ) + question = Question.objects.get(text="question") + self.assertEqual(0, question.tags.all().count()) + self.assertEqual(1, Tag.objects.all().count()) + + def test_update_existing_tag(self): + """ + Ensure a student can update a question with existing tags. + """ + + test( + self, + "student", + "modify-tag-existing", + "patch", + reverse("ohq:question-detail", args=[self.course.id, self.queue.id, self.question.id]), + {"tags": [{"name": "existing-tag"}]}, + ) + + def test_update_new_tag(self): + """ + Ensure a student can not update a question with existing tags. + """ + + test( + self, + "student", + "modify-tag-new", + "patch", + reverse("ohq:question-detail", args=[self.course.id, self.queue.id, self.question.id]), + {"tags": [{"name": "new-tag"}]}, + ) + + +class QuestionSearchTestCase(TestCase): + def setUp(self): + setUp(self) + self.queue = Queue.objects.create(name="Queue", course=self.course) + self.question = Question.objects.create(queue=self.queue, asked_by=self.student) + + # Expected results + self.expected = { + "list": { + "professor": 200, + "head_ta": 200, + "ta": 200, + "student": 403, + "non_member": 403, + "anonymous": 403, + }, + } + + @parameterized.expand(users, name_func=get_test_name) + def test_list(self, user): + test( + self, user, "list", "get", reverse("ohq:questionsearch", args=[self.course.id]), + ) + + +class MembershipTestCase(TestCase): + def setUp(self): + setUp(self) + self.membership = Membership.objects.get(course=self.course, user=self.professor) + + # Expected results + self.expected = { + "list": { + "professor": 200, + "head_ta": 200, + "ta": 200, + "student": 200, + "non_member": 403, + "anonymous": 403, + }, + "create-open": { + "professor": 403, + "head_ta": 403, + "ta": 403, + "student": 403, + "non_member": 201, + "anonymous": 403, + }, + "create-invite-only": { + "professor": 403, + "head_ta": 403, + "ta": 403, + "student": 403, + "non_member": 403, + "anonymous": 403, + }, + "retrieve": { + "professor": 200, + "head_ta": 200, + "ta": 200, + "student": 403, + "non_member": 403, + "anonymous": 403, + }, + "destroy": { + "professor": 204, + "head_ta": 204, + "ta": 403, + "student": 403, + "non_member": 403, + "anonymous": 403, + }, + "modify": { + "professor": 200, + "head_ta": 200, + "ta": 403, + "student": 403, + "non_member": 403, + "anonymous": 403, + }, + } + + @parameterized.expand(users, name_func=get_test_name) + def test_list(self, user): + test(self, user, "list", "get", reverse("ohq:member-list", args=[self.course.id])) + + @parameterized.expand(users, name_func=get_test_name) + def test_create_open(self, user): + test( + self, + user, + "create-open", + "post", + reverse("ohq:member-list", args=[self.course.id]), + {}, + ) + + @parameterized.expand(users, name_func=get_test_name) + def test_create_invite_only(self, user): + self.course.invite_only = True + self.course.save() + test( + self, + user, + "create-invite-only", + "post", + reverse("ohq:member-list", args=[self.course.id]), + {}, + ) + + @parameterized.expand(users, name_func=get_test_name) + def test_retrieve(self, user): + test( + self, + user, + "retrieve", + "get", + reverse("ohq:member-detail", args=[self.course.id, self.membership.id]), + ) + + @parameterized.expand(users, name_func=get_test_name) + def test_destroy(self, user): + test( + self, + user, + "destroy", + "delete", + reverse("ohq:member-detail", args=[self.course.id, self.membership.id]), + ) + + @parameterized.expand(users, name_func=get_test_name) + def test_modify(self, user): + test( + self, + user, + "modify", + "patch", + reverse("ohq:member-detail", args=[self.course.id, self.membership.id]), + {"description": "new"}, + ) + + +class MembershipInviteTestCase(TestCase): + def setUp(self): + setUp(self) + self.invite = MembershipInvite.objects.create(course=self.course, email="me@example.com") + + # Expected results + self.expected = { + "list": { + "professor": 200, + "head_ta": 200, + "ta": 200, + "student": 403, + "non_member": 403, + "anonymous": 403, + }, + "create": { + "professor": 201, + "head_ta": 201, + "ta": 403, + "student": 403, + "non_member": 403, + "anonymous": 403, + }, + "retrieve": { + "professor": 200, + "head_ta": 200, + "ta": 200, + "student": 403, + "non_member": 403, + "anonymous": 403, + }, + "destroy": { + "professor": 204, + "head_ta": 204, + "ta": 403, + "student": 403, + "non_member": 403, + "anonymous": 403, + }, + "modify": { + "professor": 200, + "head_ta": 200, + "ta": 403, + "student": 403, + "non_member": 403, + "anonymous": 403, + }, + } + + @parameterized.expand(users, name_func=get_test_name) + def test_list(self, user): + test(self, user, "list", "get", reverse("ohq:invite-list", args=[self.course.id])) + + @parameterized.expand(users, name_func=get_test_name) + def test_create(self, user): + test( + self, + user, + "create", + "post", + reverse("ohq:invite-list", args=[self.course.id]), + {"email": "test@example.com", "kind": Membership.KIND_STUDENT}, + ) + + @parameterized.expand(users, name_func=get_test_name) + def test_retrieve(self, user): + test( + self, + user, + "retrieve", + "get", + reverse("ohq:invite-detail", args=[self.course.id, self.invite.id]), + ) + + @parameterized.expand(users, name_func=get_test_name) + def test_destroy(self, user): + test( + self, + user, + "destroy", + "delete", + reverse("ohq:invite-detail", args=[self.course.id, self.invite.id]), + ) + + @parameterized.expand(users, name_func=get_test_name) + def test_modify(self, user): + test( + self, + user, + "modify", + "patch", + reverse("ohq:invite-detail", args=[self.course.id, self.invite.id]), + {"description": "new"}, + ) + + +class MassInviteTestCase(TestCase): + def setUp(self): + setUp(self) + + # Expected results + self.expected = { + "create": { + "professor": 201, + "head_ta": 201, + "ta": 403, + "student": 403, + "non_member": 403, + "anonymous": 403, + } + } + + @parameterized.expand(users, name_func=get_test_name) + def test_create(self, user): + test( + self, + user, + "create", + "post", + reverse("ohq:mass-invite", args=[self.course.id]), + {"emails": "test@example.com,test2@example.com", "kind": Membership.KIND_STUDENT}, + ) + + +class CourseStatisticTestCase(TestCase): + def setUp(self): + setUp(self) + + # Expected results + self.expected = { + "list": { + "professor": 200, + "head_ta": 200, + "ta": 200, + "student": 403, + "non_member": 403, + "anonymous": 403, + }, + } + + @parameterized.expand(users, name_func=get_test_name) + def test_list(self, user): + test( + self, user, "list", "get", reverse("ohq:course-statistic", args=[self.course.id]), + ) + + +class QueueStatisticTestCase(TestCase): + def setUp(self): + setUp(self) + self.queue = Queue.objects.create(name="Queue", course=self.course) + + # Expected results + self.expected = { + "list": { + "professor": 200, + "head_ta": 200, + "ta": 200, + "student": 200, + "non_member": 403, + "anonymous": 403, + }, + } + + @parameterized.expand(users, name_func=get_test_name) + def test_list(self, user): + test( + self, + user, + "list", + "get", + reverse("ohq:queue-statistic", args=[self.course.id, self.queue.id]), + ) + + +class AnnouncementTestCase(TestCase): + def setUp(self): + setUp(self) + self.announcement = Announcement.objects.create( + course=self.course, author=self.professor, content="Original announcement" + ) + + # Expected results + self.expected = { + "list": { + "professor": 200, + "head_ta": 200, + "ta": 200, + "student": 200, + "non_member": 403, + "anonymous": 403, + }, + "create": { + "professor": 201, + "head_ta": 201, + "ta": 201, + "student": 403, + "non_member": 403, + "anonymous": 403, + }, + "retrieve": { + "professor": 200, + "head_ta": 200, + "ta": 200, + "student": 200, + "non_member": 403, + "anonymous": 403, + }, + "destroy": { + "professor": 204, + "head_ta": 204, + "ta": 204, + "student": 403, + "non_member": 403, + "anonymous": 403, + }, + "modify": { + "professor": 200, + "head_ta": 200, + "ta": 200, + "student": 403, + "non_member": 403, + "anonymous": 403, + }, + } + + @parameterized.expand(users, name_func=get_test_name) + def test_list(self, user): + test(self, user, "list", "get", reverse("ohq:announcement-list", args=[self.course.id])) + + @parameterized.expand(users, name_func=get_test_name) + def test_create(self, user): + content = "New announcement" + test( + self, + user, + "create", + "post", + reverse("ohq:announcement-list", args=[self.course.id]), + {"content": content}, + ) + + @parameterized.expand(users, name_func=get_test_name) + def test_retrieve(self, user): + test( + self, + user, + "retrieve", + "get", + reverse("ohq:announcement-detail", args=[self.course.id, self.announcement.id]), + ) + + @parameterized.expand(users, name_func=get_test_name) + def test_destroy(self, user): + test( + self, + user, + "destroy", + "delete", + reverse("ohq:announcement-detail", args=[self.course.id, self.announcement.id]), + ) + + @parameterized.expand(users, name_func=get_test_name) + def test_modify(self, user): + test( + self, + user, + "modify", + "patch", + reverse("ohq:announcement-detail", args=[self.course.id, self.announcement.id]), + {"content": "Updated announcement"}, + ) + + +class EventTestCase(TestCase): + def setUp(self): + setUp(self) + self.default_calendar = Calendar.objects.create(name="DefaultCalendar") + self.event = Event.objects.create( + title="Event", + calendar=self.default_calendar, + rule=None, + start=datetime.now(tz=pytz.utc), + end=datetime.now(tz=pytz.utc), + ) + erm = EventRelationManager() + erm.create_relation(event=self.event, content_object=self.course) + + self.start_time = "2019-08-24T14:15:22Z" + self.end_time = "2019-09-24T14:15:22Z" + self.title = "TA Session" + self.new_title = "New TA Session" + + # Expected results + self.expected = { + "list": { + "professor": 200, + "head_ta": 200, + "ta": 200, + "student": 200, + "non_member": 403, + "anonymous": 403, + }, + "create": { + "professor": 201, + "head_ta": 201, + "ta": 201, + "student": 403, + "non_member": 403, + "anonymous": 403, + }, + "retrieve": { + "professor": 200, + "head_ta": 200, + "ta": 200, + "student": 200, + "non_member": 403, + "anonymous": 403, + }, + "destroy": { + "professor": 204, + "head_ta": 204, + "ta": 204, + "student": 403, + "non_member": 403, + "anonymous": 403, + }, + "modify": { + "professor": 200, + "head_ta": 200, + "ta": 200, + "student": 403, + "non_member": 403, + "anonymous": 403, + }, + } + + @parameterized.expand(users, name_func=get_test_name) + def test_list(self, user): + test(self, user, "list", "get", "/api/events/?course=" + str(self.course.id)) + + @parameterized.expand(users, name_func=get_test_name) + def test_create(self, user): + test( + self, + user, + "create", + "post", + reverse("ohq:event-list"), + { + "start": self.start_time, + "end": self.end_time, + "title": self.title, + "rule": {"frequency": "WEEKLY"}, + "endRecurringPeriod": self.end_time, + "courseId": self.course.id, + }, + ) + + @parameterized.expand(users, name_func=get_test_name) + def test_retrieve(self, user): + test( + self, user, "retrieve", "get", reverse("ohq:event-detail", args=[self.event.id]), + ) + + @parameterized.expand(users, name_func=get_test_name) + def test_destroy(self, user): + test(self, user, "destroy", "delete", reverse("ohq:event-detail", args=[self.event.id])) + + @parameterized.expand(users, name_func=get_test_name) + def test_modify(self, user): + test( + self, + user, + "modify", + "patch", + reverse("ohq:event-detail", args=[self.event.id]), + {"title": self.new_title, "courseId": self.course.id}, + ) + + +class OccurrenceTestCase(TestCase): + def setUp(self): + setUp(self) + + self.start_time = datetime.strptime("2021-12-05T12:41:37Z", "%Y-%m-%dT%H:%M:%SZ").replace( + tzinfo=pytz.utc + ) + self.end_time = datetime.strptime("2021-12-06T12:41:37Z", "%Y-%m-%dT%H:%M:%SZ").replace( + tzinfo=pytz.utc + ) + self.title = "TA Session" + self.new_title = "New TA Session" + self.default_calendar = Calendar.objects.create(name="DefaultCalendar") + self.event = Event.objects.create( + title="Event", + calendar=self.default_calendar, + rule=None, + start=self.start_time, + end=self.end_time, + ) + erm = EventRelationManager() + erm.create_relation(event=self.event, content_object=self.course) + + self.filter_start = "2021-12-05T12:40:37Z" + self.filter_end = "2021-12-12T12:42:37Z" + + self.occurrence = self.event.get_occurrences( + datetime.strptime(self.filter_start, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=pytz.utc), + datetime.strptime(self.filter_end, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=pytz.utc), + )[0] + self.occurrence.save() + + # Expected results + self.expected = { + "list": { + "professor": 200, + "head_ta": 200, + "ta": 200, + "student": 200, + "non_member": 403, + "anonymous": 403, + }, + "retrieve": { + "professor": 200, + "head_ta": 200, + "ta": 200, + "student": 200, + "non_member": 403, + "anonymous": 403, + }, + "modify": { + "professor": 200, + "head_ta": 200, + "ta": 200, + "student": 403, + "non_member": 403, + "anonymous": 403, + }, + } + + @parameterized.expand(users, name_func=get_test_name) + def test_list(self, user): + test( + self, + user, + "list", + "get", + "/api/occurrences/?course=" + + str(self.course.id) + + "&filter_start=" + + self.filter_start + + "&filter_end=" + + self.filter_end, + ) + + @parameterized.expand(users, name_func=get_test_name) + def test_retrieve(self, user): + test( + self, + user, + "retrieve", + "get", + reverse("ohq:occurrence-detail", args=[self.occurrence.id]), + ) + + @parameterized.expand(users, name_func=get_test_name) + def test_modify(self, user): + test( + self, + user, + "modify", + "patch", + reverse("ohq:occurrence-detail", args=[self.occurrence.id]), + {"title": self.new_title, "courseId": self.course.id}, + ) diff --git a/backend/tests/ohq/test_queues.py b/backend/tests/ohq/test_queues.py new file mode 100644 index 00000000..663da461 --- /dev/null +++ b/backend/tests/ohq/test_queues.py @@ -0,0 +1,81 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.utils import timezone + +from ohq.models import Course, Membership, Question, Queue, Semester +from ohq.queues import calculate_wait_times + + +User = get_user_model() + + +class generateWaitTimesTestCase(TestCase): + def setUp(self): + self.semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + self.course = Course.objects.create( + course_code="000", department="Penn Labs", semester=self.semester + ) + self.open_queue = Queue.objects.create(name="Queue", course=self.course, active=True) + self.closed_queue = Queue.objects.create( + name="Closed Queue", course=self.course, estimated_wait_time=5 + ) + + self.ta = User.objects.create(username="ta") + self.student = User.objects.create(username="student") + Membership.objects.create(course=self.course, user=self.ta, kind=Membership.KIND_TA) + Membership.objects.create( + course=self.course, user=self.student, kind=Membership.KIND_STUDENT + ) + + self.now = timezone.now() + q1 = Question.objects.create( + queue=self.open_queue, asked_by=self.student, text="Q1", time_response_started=self.now, + ) + q1.time_asked = self.now - timedelta(minutes=3) + q1.save() + q2 = Question.objects.create( + queue=self.open_queue, asked_by=self.student, text="Q2", time_response_started=self.now, + ) + q2.time_asked = self.now - timedelta(minutes=3) + q2.save() + q3 = Question.objects.create( + queue=self.open_queue, asked_by=self.student, text="Q3", time_response_started=self.now, + ) + q3.time_asked = self.now - timedelta(minutes=4) + q3.save() + q4 = Question.objects.create( + queue=self.open_queue, asked_by=self.student, text="Q4", time_response_started=self.now, + ) + q4.time_asked = self.now - timedelta(minutes=6) + q4.save() + + def test_closed_queue(self): + """ + Ensure a closed queue's wait time is -1 + """ + + calculate_wait_times() + self.closed_queue.refresh_from_db() + self.assertEqual(-1, self.closed_queue.estimated_wait_time) + + def test_open_queue_no_questions(self): + """ + If a queue is open, but doesn't have any questions answered in the last 5 minutes, + estimated time should be 0 + """ + + Question.objects.all().delete() + calculate_wait_times() + self.open_queue.refresh_from_db() + self.assertEqual(0, self.open_queue.estimated_wait_time) + + def test_open_queue_with_questions(self): + """ + If a queue is open, and has answered questions, calculate estimated wait time + """ + + calculate_wait_times() + self.open_queue.refresh_from_db() + self.assertEqual(4, self.open_queue.estimated_wait_time) diff --git a/backend/tests/ohq/test_serializers.py b/backend/tests/ohq/test_serializers.py new file mode 100644 index 00000000..2010c641 --- /dev/null +++ b/backend/tests/ohq/test_serializers.py @@ -0,0 +1,725 @@ +import json +from datetime import datetime +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import RequestFactory, TestCase +from django.urls import reverse +from django.utils import timezone +from rest_framework import serializers +from rest_framework.test import APIClient +from schedule.models import Event + +from ohq.models import Announcement, Course, Membership, Question, Queue, Semester, Tag +from ohq.serializers import ( + CourseCreateSerializer, + MembershipSerializer, + SemesterSerializer, + UserPrivateSerializer, +) + + +User = get_user_model() + + +class UserPrivateSerializerTestCase(TestCase): + def setUp(self): + self.user = User.objects.create(username="user") + self.serializer = UserPrivateSerializer(instance=self.user) + + def test_set_sms_notifications_enabled(self): + self.assertFalse(self.user.profile.sms_notifications_enabled) + self.serializer.update(self.user, {"profile": {"sms_notifications_enabled": True}}) + self.assertTrue(self.user.profile.sms_notifications_enabled) + + def test_set_new_phone_number(self): + self.assertIsNone(self.user.profile.sms_verification_timestamp) + phone_number = "+15555555555" + self.serializer.update(self.user, {"profile": {"phone_number": phone_number}}) + self.assertFalse(self.user.profile.sms_verified) + self.assertEqual(self.user.profile.phone_number, phone_number) + self.assertIsNotNone(self.user.profile.sms_verification_timestamp) + self.assertRegex(self.user.profile.sms_verification_code, "[0-9]{6}") + + def test_verify_phone_number(self): + self.serializer.update(self.user, {"profile": {"phone_number": "+15555555555"}}) + self.serializer.update( + self.user, + {"profile": {"sms_verification_code": self.user.profile.sms_verification_code}}, + ) + self.assertTrue(self.user.profile.sms_verified) + + def test_verify_phone_number_invalid(self): + self.serializer.update(self.user, {"profile": {"phone_number": "+15555555555"}}) + with self.assertRaises(serializers.ValidationError): + self.serializer.update( + self.user, {"profile": {"sms_verification_code": "ABC123"}}, + ) + + def test_verify_phone_number_time_expired(self): + self.serializer.update(self.user, {"profile": {"phone_number": "+15555555555"}}) + date = timezone.make_aware(datetime(2020, 1, 1)) + self.user.profile.sms_verification_timestamp = date + with self.assertRaises(serializers.ValidationError): + self.serializer.update( + self.user, + {"profile": {"sms_verification_code": self.user.profile.sms_verification_code}}, + ) + + +class CourseCreateSerializerTestCase(TestCase): + def test_create_membership(self): + semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + user = User.objects.create(username="user") + data = { + "course_code": "000", + "department": "Penn Labs", + "course_title": "Course", + "semester": semester.id, + "created_role": Membership.KIND_HEAD_TA, + } + + request = RequestFactory().get("/") + request.user = user + serializer = CourseCreateSerializer(data=data, context={"request": request}) + serializer.is_valid() + serializer.save() + self.assertEqual(1, len(Membership.objects.all())) + membership = Membership.objects.get(user=user) + self.assertEqual(membership.kind, Membership.KIND_HEAD_TA) + + +class MembershipSerializerTestCase(TestCase): + def test_create_membership(self): + semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + course = Course.objects.create(course_code="000", department="Penn Labs", semester=semester) + + user = User.objects.create(username="professor") + + class View(object): + """ + Mock view object to provide a course pk + """ + + def __init__(self): + self.kwargs = {"course_pk": course.id} + + request = RequestFactory().get("/") + request.user = user + serializer = MembershipSerializer(data={}, context={"request": request, "view": View()}) + serializer.is_valid() + serializer.save() + self.assertEqual(1, len(Membership.objects.all())) + + +class SemesterSerializerTestCase(TestCase): + def test_pretty(self): + semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + serializer = SemesterSerializer(instance=semester) + self.assertEqual(serializer.data["pretty"], str(semester)) + + +class QueueSerializerTestCase(TestCase): + def setUp(self): + self.client = APIClient() + self.semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + self.course = Course.objects.create( + course_code="000", department="Penn Labs", semester=self.semester + ) + self.name = "Queue" + self.queue = Queue.objects.create(name=self.name, course=self.course) + self.pin = "AAAAA" + self.pin_queue_name = "Pin Queue" + self.pin_queue = Queue.objects.create( + name=self.pin_queue_name, course=self.course, pin_enabled=True, pin=self.pin + ) + self.head_ta = User.objects.create(username="head_ta") + self.ta = User.objects.create(username="ta") + self.student = User.objects.create(username="student") + Membership.objects.create( + course=self.course, user=self.head_ta, kind=Membership.KIND_HEAD_TA + ) + Membership.objects.create(course=self.course, user=self.ta, kind=Membership.KIND_TA) + Membership.objects.create( + course=self.course, user=self.student, kind=Membership.KIND_STUDENT + ) + + def test_update_active_ta(self): + """ + Ensure TAs can open and close a queue. + """ + + # TAs+ can activate queue but not students + self.assertFalse(self.queue.active) + self.client.force_authenticate(user=self.ta) + self.client.patch( + reverse("ohq:queue-detail", args=[self.course.id, self.queue.id]), {"active": True} + ) + self.queue.refresh_from_db() + self.assertTrue(self.queue.active) + + self.queue.active = False + self.queue.save() + self.client.force_authenticate(user=self.student) + self.client.patch( + reverse("ohq:queue-detail", args=[self.course.id, self.queue.id]), {"active": True} + ) + self.assertFalse(self.queue.active) + + def test_update_other_ta(self): + """ + TAs can't modify other attributes of a queue + """ + + self.assertEqual(self.name, self.queue.name) + self.client.force_authenticate(user=self.ta) + self.client.patch( + reverse("ohq:queue-detail", args=[self.course.id, self.queue.id]), {"name": "New"} + ) + self.queue.refresh_from_db() + self.assertEqual(self.name, self.queue.name) + + def test_update_leadership(self): + """ + Course leadership can modify anything + """ + + self.assertEqual(self.name, self.queue.name) + self.client.force_authenticate(user=self.head_ta) + new_name = "New Queue Name" + self.client.patch( + reverse("ohq:queue-detail", args=[self.course.id, self.queue.id]), {"name": new_name} + ) + self.queue.refresh_from_db() + self.assertEqual(new_name, self.queue.name) + + def test_generate_pin(self): + """ + Ensure pin is generated when queue is opened + """ + # queue without pin enabled shouldn't generate pin + self.client.force_authenticate(user=self.head_ta) + old_pin = self.queue.pin + self.client.patch( + reverse("ohq:queue-detail", args=[self.course.id, self.queue.id]), {"active": True} + ) + self.queue.refresh_from_db() + self.assertEquals(old_pin, self.queue.pin) + + # queue with pin enabled generates new pin + self.queue.pin_enabled = True + self.queue.active = False + self.queue.save() + self.client.patch( + reverse("ohq:queue-detail", args=[self.course.id, self.queue.id]), {"active": True} + ) + self.queue.refresh_from_db() + self.assertNotEquals(old_pin, self.queue.pin) + + self.queue.active = False + self.queue.save() + old_pin = self.queue.pin + self.client.force_authenticate(user=self.ta) + self.client.patch( + reverse("ohq:queue-detail", args=[self.course.id, self.queue.id]), {"active": True} + ) + self.queue.refresh_from_db() + self.assertNotEquals(old_pin, self.queue.pin) + + # TAs+ (but not student) can change pin + self.queue.pin_enabled = True + self.queue.save() + manual_update_pin = "BBBBB" + self.client.force_authenticate(user=self.ta) + self.client.patch( + reverse("ohq:queue-detail", args=[self.course.id, self.queue.id]), + {"pin": manual_update_pin}, + ) + self.queue.refresh_from_db() + self.assertEquals(manual_update_pin, self.queue.pin) + + self.queue.pin = self.pin + self.queue.save() + self.client.force_authenticate(user=self.student) + self.client.patch( + reverse("ohq:queue-detail", args=[self.course.id, self.queue.id]), + {"pin": manual_update_pin}, + ) + self.queue.refresh_from_db() + self.assertNotEquals(manual_update_pin, self.queue.pin) + self.assertEquals(self.pin, self.queue.pin) + + def test_get_pin(self): + """ + Ensure only TAs can get pin in queue detail + """ + # student should not get pin + self.client.force_authenticate(user=self.student) + response = self.client.get( + reverse("ohq:queue-detail", args=[self.course.id, self.pin_queue.id]) + ) + content = json.loads(response.content) + self.assertTrue("pin" not in content) + + # TAs should get pin + self.client.force_authenticate(user=self.ta) + response = self.client.get( + reverse("ohq:queue-detail", args=[self.course.id, self.pin_queue.id]) + ) + content = json.loads(response.content) + self.assertEquals(content["pin"], self.pin) + + +@patch("ohq.serializers.sendUpNextNotificationTask.delay") +class QuestionSerializerTestCase(TestCase): + def setUp(self): + self.client = APIClient() + self.semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + self.course = Course.objects.create( + course_code="000", department="Penn Labs", semester=self.semester + ) + self.course2 = Course.objects.create( + course_code="001", department="Penn Labs", semester=self.semester + ) + self.queue = Queue.objects.create(name="Queue", course=self.course) + self.queue2 = Queue.objects.create(name="Queue", course=self.course2) + self.ta = User.objects.create(username="ta") + self.student = User.objects.create(username="student") + self.student2 = User.objects.create(username="student2") + Tag.objects.create(course=self.course, name="Tag") + Tag.objects.create(course=self.course2, name="Tag") + Membership.objects.create(course=self.course, user=self.ta, kind=Membership.KIND_TA) + Membership.objects.create( + course=self.course, user=self.student, kind=Membership.KIND_STUDENT + ) + Membership.objects.create( + course=self.course, user=self.student2, kind=Membership.KIND_STUDENT + ) + self.question_text = "This is a question" + self.question = Question.objects.create( + queue=self.queue, asked_by=self.student, text=self.question_text + ) + + def test_create(self, mock_delay): + self.client.force_authenticate(user=self.student2) + self.client.post( + reverse("ohq:question-list", args=[self.course.id, self.queue.id]), + {"text": "Help me", "tags": [{"name": "Tag"}]}, + ) + self.assertEqual(2, Question.objects.all().count()) + question = Question.objects.all().order_by("time_asked")[1] + self.assertEqual(self.student2, question.asked_by) + self.assertEqual(Question.STATUS_ASKED, question.status) + mock_delay.assert_not_called() + + def test_student_update(self, mock_delay): + """ + Ensure a student can update their question's text and videochat link. + """ + + text = "Different" + url = "https://example.com" + student_descriptor = "In the back" + self.client.force_authenticate(user=self.student) + self.client.patch( + reverse("ohq:question-detail", args=[self.course.id, self.queue.id, self.question.id]), + { + "text": text, + "video_chat_url": url, + "tags": [{"name": "Tag"}], + "student_descriptor": student_descriptor, + }, + ) + self.question.refresh_from_db() + self.assertEqual(text, self.question.text) + self.assertEqual(url, self.question.video_chat_url) + self.assertEqual(student_descriptor, self.question.student_descriptor) + mock_delay.assert_not_called() + + def test_student_update_note(self, mock_delay): + """ + Ensure a note is removed when a student modifies their question. + """ + + self.question.note = "Make changes" + self.question.resolved_note = False + self.question.save() + self.client.force_authenticate(user=self.student) + self.client.patch( + reverse("ohq:question-detail", args=[self.course.id, self.queue.id, self.question.id]), + {"text": "different"}, + ) + self.question.refresh_from_db() + self.assertTrue(self.question.resolved_note) + self.assertEqual(self.question.note, "") + mock_delay.assert_not_called() + + def test_student_withdraw(self, mock_delay): + """ + Ensure a student can withdraw their question. + """ + + self.client.force_authenticate(user=self.student) + self.client.patch( + reverse("ohq:question-detail", args=[self.course.id, self.queue.id, self.question.id]), + {"status": Question.STATUS_WITHDRAWN}, + ) + self.question.refresh_from_db() + self.assertEqual(Question.STATUS_WITHDRAWN, self.question.status) + mock_delay.assert_called() + + def test_student_active(self, mock_delay): + """ + Ensure students can't mark a question as active + """ + + self.client.force_authenticate(user=self.student) + response = self.client.patch( + reverse("ohq:question-detail", args=[self.course.id, self.queue.id, self.question.id]), + {"status": Question.STATUS_ACTIVE}, + ) + self.assertEqual(400, response.status_code) + self.question.refresh_from_db() + self.assertEqual(Question.STATUS_ASKED, self.question.status) + mock_delay.assert_not_called() + + def test_student_answered(self, mock_delay): + """ + Ensure a student can mark their question as answered + """ + + self.client.force_authenticate(user=self.student) + self.client.patch( + reverse("ohq:question-detail", args=[self.course.id, self.queue.id, self.question.id]), + {"status": Question.STATUS_ANSWERED}, + ) + self.question.refresh_from_db() + self.assertEqual(Question.STATUS_ANSWERED, self.question.status) + mock_delay.assert_not_called() + + def test_ta_update(self, mock_delay): + """ + Ensure TAs+ can start answering, undo answering, and reject a question + """ + + self.client.force_authenticate(user=self.ta) + # Start answering the question + self.client.patch( + reverse("ohq:question-detail", args=[self.course.id, self.queue.id, self.question.id]), + {"status": Question.STATUS_ACTIVE}, + ) + self.question.refresh_from_db() + self.assertEqual(Question.STATUS_ACTIVE, self.question.status) + self.assertEqual(self.ta, self.question.responded_to_by) + # Add a note to the question + note = "Invalid zoom link" + self.client.patch( + reverse("ohq:question-detail", args=[self.course.id, self.queue.id, self.question.id]), + {"note": note}, + ) + self.question.refresh_from_db() + self.assertFalse(self.question.resolved_note) + self.assertEqual(self.question.note, note) + + # Undo and put user back in queue + self.client.patch( + reverse("ohq:question-detail", args=[self.course.id, self.queue.id, self.question.id]), + {"status": Question.STATUS_ASKED}, + ) + self.question.refresh_from_db() + self.assertEqual(Question.STATUS_ASKED, self.question.status) + self.assertIsNone(self.question.responded_to_by) + # Reject the question + rejected_reason = "This is a rejection" + self.client.patch( + reverse("ohq:question-detail", args=[self.course.id, self.queue.id, self.question.id]), + {"status": Question.STATUS_REJECTED, "rejected_reason": rejected_reason}, + ) + self.question.refresh_from_db() + self.assertEqual(Question.STATUS_REJECTED, self.question.status) + self.assertEqual(self.ta, self.question.responded_to_by) + self.assertEqual(rejected_reason, self.question.rejected_reason) + mock_delay.assert_called() + + def test_ta_answer(self, mock_delay): + """ + Ensure TAs+ can answer a question + """ + + self.client.force_authenticate(user=self.ta) + # Start answering the question + self.client.patch( + reverse("ohq:question-detail", args=[self.course.id, self.queue.id, self.question.id]), + {"status": Question.STATUS_ACTIVE}, + ) + self.question.refresh_from_db() + self.assertEqual(Question.STATUS_ACTIVE, self.question.status) + self.assertEqual(self.ta, self.question.responded_to_by) + # Finish answering the question + self.client.patch( + reverse("ohq:question-detail", args=[self.course.id, self.queue.id, self.question.id]), + {"status": Question.STATUS_ANSWERED}, + ) + self.question.refresh_from_db() + self.assertEqual(Question.STATUS_ANSWERED, self.question.status) + self.assertEqual(self.ta, self.question.responded_to_by) + mock_delay.assert_called() + + def test_ta_update_text(self, mock_delay): + """ + Ensure TAs+ can't modify a question's contents + """ + + self.client.force_authenticate(user=self.ta) + response = self.client.patch( + reverse("ohq:question-detail", args=[self.course.id, self.queue.id, self.question.id]), + {"text": "Different"}, + ) + self.assertEqual(200, response.status_code) + self.question.refresh_from_db() + self.assertEqual(self.question_text, self.question.text) + mock_delay.assert_not_called() + + def test_ta_withdraw(self, mock_delay): + """ + Ensure TAs+ can't mark a question as withdrawn + """ + + self.client.force_authenticate(user=self.ta) + response = self.client.patch( + reverse("ohq:question-detail", args=[self.course.id, self.queue.id, self.question.id]), + {"status": Question.STATUS_WITHDRAWN}, + ) + self.assertEqual(400, response.status_code) + self.question.refresh_from_db() + self.assertEqual(Question.STATUS_ASKED, self.question.status) + mock_delay.assert_not_called() + + +class AnnouncementSerializerTestCase(TestCase): + def setUp(self): + self.client = APIClient() + self.semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + self.course = Course.objects.create( + course_code="000", department="Penn Labs", semester=self.semester + ) + self.name = "Queue" + self.queue = Queue.objects.create(name=self.name, course=self.course) + self.head_ta = User.objects.create(username="head_ta") + self.ta = User.objects.create(username="ta") + Membership.objects.create( + course=self.course, user=self.head_ta, kind=Membership.KIND_HEAD_TA + ) + Membership.objects.create(course=self.course, user=self.ta, kind=Membership.KIND_TA) + self.announcement = Announcement.objects.create( + course=self.course, author=self.head_ta, content="Original announcement" + ) + + def test_update_ta(self): + """ + Ensure TAs can update an Announcement and the author is updated + """ + content = "New content" + self.client.force_authenticate(user=self.ta) + self.client.patch( + reverse("ohq:announcement-detail", args=[self.course.id, self.announcement.id]), + {"content": content}, + ) + self.announcement.refresh_from_db() + self.assertTrue(self.announcement.author, self.ta) + + def test_create(self): + """ + Ensure TAs can create an Announcement and the author matches + """ + self.client.force_authenticate(user=self.ta) + self.client.post( + reverse("ohq:announcement-list", args=[self.course.id]), {"content": "New announcement"} + ) + self.assertEqual(2, Announcement.objects.all().count()) + announcement = Announcement.objects.all().order_by("time_updated")[1] + self.assertEqual(self.ta, announcement.author) + + +class EventSerializerTestCase(TestCase): + def setUp(self): + self.client = APIClient() + self.semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + self.course = Course.objects.create( + course_code="000", department="Penn Labs", semester=self.semester + ) + self.head_ta = User.objects.create(username="head_ta") + self.ta = User.objects.create(username="ta") + self.student = User.objects.create(username="student") + Membership.objects.create( + course=self.course, user=self.head_ta, kind=Membership.KIND_HEAD_TA + ) + Membership.objects.create(course=self.course, user=self.ta, kind=Membership.KIND_TA) + Membership.objects.create( + course=self.course, user=self.student, kind=Membership.KIND_STUDENT + ) + self.old_title = "TA session" + self.new_title = "New TA session" + self.start_time = "2019-08-24T14:15:22Z" + self.end_time = "2019-09-24T14:15:22Z" + + def test_create(self): + """ + Ensure TAs can create Event + """ + self.client.force_authenticate(user=self.ta) + self.client.post( + reverse("ohq:event-list"), + { + "start": self.start_time, + "end": self.end_time, + "title": self.old_title, + "rule": {"frequency": "WEEKLY"}, + "end_recurring_period": self.end_time, + "course_id": self.course.id, + }, + ) + self.assertEqual(1, Event.objects.all().count()) + + # creating event without rule works + self.client.post( + reverse("ohq:event-list"), + { + "start": self.start_time, + "end": self.end_time, + "title": self.old_title, + "end_recurring_period": self.end_time, + "course_id": self.course.id, + }, + ) + self.assertEqual(2, Event.objects.all().count()) + + # creating without course_id does not + self.client.post( + reverse("ohq:event-list"), + { + "start": self.start_time, + "end": self.end_time, + "title": self.old_title, + "rule": {"frequency": "WEEKLY"}, + "end_recurring_period": self.end_time, + }, + ) + self.assertEqual(2, Event.objects.all().count()) + + # student cannot create new event + self.client.force_authenticate(user=self.student) + self.client.post( + reverse("ohq:event-list"), + { + "start": self.start_time, + "end": self.end_time, + "title": self.old_title, + "rule": {"frequency": "WEEKLY"}, + "end_recurring_period": "2019-10-24T14:15:22Z", + "course_id": self.course.id, + }, + ) + self.assertEqual(2, Event.objects.all().count()) + + def test_update(self): + """ + Ensure TAs can update event + """ + self.client.force_authenticate(user=self.ta) + self.client.post( + reverse("ohq:event-list"), + { + "start": self.start_time, + "end": self.end_time, + "title": self.old_title, + "rule": {"frequency": "WEEKLY"}, + "end_recurring_period": self.end_time, + "course_id": self.course.id, + }, + ) + self.assertEqual(1, Event.objects.all().count()) + event = Event.objects.all().first() + self.client.patch( + reverse("ohq:event-detail", args=[event.id]), + { + "title": self.new_title, + "course_id": self.course.id, + "rule": {"frequency": "MONTHLY"}, + }, + ) + event = Event.objects.all().first() + self.assertEquals(event.title, self.new_title) + self.assertEquals(event.rule.frequency, "MONTHLY") + # student cannot make changes + self.client.force_authenticate(user=self.student) + self.client.patch( + reverse("ohq:event-detail", args=[event.id]), + {"title": self.old_title, "course_id": self.course.id}, + ) + event = Event.objects.all().first() + # title has not changed + self.assertEquals(event.title, self.new_title) + self.assertEquals(event.rule.frequency, "MONTHLY") + + def test_update_no_rule(self): + """ + Ensure TAs can update event without Rule + """ + self.client.force_authenticate(user=self.ta) + self.client.post( + reverse("ohq:event-list"), + { + "start": self.start_time, + "end": self.end_time, + "title": self.old_title, + "rule": {"frequency": "WEEKLY"}, + "end_recurring_period": self.end_time, + "course_id": self.course.id, + }, + ) + self.assertEqual(1, Event.objects.all().count()) + event = Event.objects.all().first() + self.client.patch( + reverse("ohq:event-detail", args=[event.id]), + {"title": self.new_title, "courseId": self.course.id}, + ) + event = Event.objects.all().first() + self.assertEquals(event.title, self.new_title) + + def test_list(self): + """ + Ensure list of events work + """ + self.client.force_authenticate(user=self.ta) + self.client.post( + reverse("ohq:event-list"), + { + "start": self.start_time, + "end": self.end_time, + "title": self.old_title, + "rule": {"frequency": "WEEKLY"}, + "end_recurring_period": self.end_time, + "course_id": self.course.id, + }, + ) + self.client.post( + reverse("ohq:event-list"), + { + "start": self.start_time, + "end": self.end_time, + "title": self.new_title, + "rule": {"frequency": "WEEKLY"}, + "end_recurring_period": self.end_time, + "course_id": self.course.id, + }, + ) + response = self.client.get( + # i don't know how to reverse this, so it is a bit clunky + "/api/events/?course=" + + str(self.course.id) + ) + data = json.loads(response.content) + self.assertEquals(2, len(data)) + self.assertEquals(self.course.id, data[0]["course_id"]) + self.assertEquals(self.course.id, data[1]["course_id"]) diff --git a/backend/tests/ohq/test_sms.py b/backend/tests/ohq/test_sms.py new file mode 100644 index 00000000..8421ec52 --- /dev/null +++ b/backend/tests/ohq/test_sms.py @@ -0,0 +1,79 @@ +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import TestCase +from twilio.base.exceptions import TwilioRestException + +from ohq.models import Course, Semester +from ohq.sms import sendSMS, sendSMSVerification, sendUpNextNotification + + +User = get_user_model() + + +class sendSMSTestCase(TestCase): + @patch("ohq.sms.capture_message") + def test_invalid_client(self, mock_sentry): + sendSMS("+15555555555", "Body") + mock_sentry.assert_called() + self.assertEqual(1, len(mock_sentry.mock_calls)) + expected = {"level": "error"} + self.assertEqual(expected, mock_sentry.call_args[1]) + + @patch("ohq.sms.capture_message") + @patch("ohq.sms.Client") + def test_rest_exception(self, mock_client, mock_sentry): + mock_client.return_value.messages.create.side_effect = TwilioRestException("", "") + sendSMS("+15555555555", "Body") + mock_sentry.assert_called() + self.assertEqual(1, len(mock_sentry.mock_calls)) + expected = {"level": "error"} + self.assertEqual(expected, mock_sentry.call_args[1]) + + @patch("ohq.sms.Client") + def test_send_sms(self, mock_client): + sendSMS("+15555555555", "Body") + mock_client.assert_called() + mock_calls = mock_client.mock_calls + self.assertEqual(2, len(mock_calls)) + expected = { + "to": "+15555555555", + "body": "Body", + "from_": "", + } + self.assertEqual(expected, mock_client.mock_calls[1][2]) + + +class sendSMSVerificationTestCase(TestCase): + @patch("ohq.sms.sendSMS") + def test_verification(self, mock_send): + phone_number = "+15555555555" + verification_code = "123456" + sendSMSVerification(phone_number, verification_code) + mock_send.assert_called() + self.assertEqual(1, len(mock_send.mock_calls)) + body = f"Your OHQ Verification Code is: {verification_code}" + self.assertEqual(phone_number, mock_send.call_args[0][0]) + self.assertEqual(body, mock_send.call_args[0][1]) + + +class sendUpNextNotificationTestCase(TestCase): + def setUp(self): + self.semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + self.course = Course.objects.create( + course_code="000", department="Penn Labs", semester=self.semester + ) + self.student = User.objects.create(username="student") + self.phone_number = "+15555555555" + self.student.profile.phone_number = self.phone_number + self.student.save() + + @patch("ohq.sms.sendSMS") + def test_notification(self, mock_send): + sendUpNextNotification(self.student, self.course) + mock_send.assert_called() + self.assertEqual(1, len(mock_send.mock_calls)) + self.assertEqual(self.phone_number, mock_send.call_args[0][0]) + course_title = f"{self.course.department} {self.course.course_code}" + body = f"You are currently 3rd in line for {course_title}, be ready soon!" + self.assertEqual(body, mock_send.call_args[0][1]) diff --git a/backend/tests/ohq/test_tasks.py b/backend/tests/ohq/test_tasks.py new file mode 100644 index 00000000..00585787 --- /dev/null +++ b/backend/tests/ohq/test_tasks.py @@ -0,0 +1,91 @@ +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import TestCase + +from ohq.models import Course, Membership, Question, Queue, Semester +from ohq.tasks import sendUpNextNotificationTask + + +User = get_user_model() + + +@patch("ohq.tasks.sendUpNextNotification") +class sendUpNextNotificationTaskTestCase(TestCase): + def setUp(self): + self.semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + self.course = Course.objects.create( + course_code="000", department="Penn Labs", semester=self.semester + ) + self.queue = Queue.objects.create(name="Queue", course=self.course) + + self.ta = User.objects.create(username="ta") + self.student_one = User.objects.create(username="student_one") + self.student_two = User.objects.create(username="student_two") + self.student_three = User.objects.create(username="student_three") + self.student_four = User.objects.create(username="student_four") + Membership.objects.create(course=self.course, user=self.ta, kind=Membership.KIND_TA) + Membership.objects.create( + course=self.course, user=self.student_one, kind=Membership.KIND_STUDENT + ) + Membership.objects.create( + course=self.course, user=self.student_two, kind=Membership.KIND_STUDENT + ) + Membership.objects.create( + course=self.course, user=self.student_three, kind=Membership.KIND_STUDENT + ) + Membership.objects.create( + course=self.course, user=self.student_four, kind=Membership.KIND_STUDENT + ) + Question.objects.create(queue=self.queue, asked_by=self.student_one, text="Q1") + Question.objects.create(queue=self.queue, asked_by=self.student_two, text="Q2") + Question.objects.create(queue=self.queue, asked_by=self.student_three, text="Q3") + Question.objects.create(queue=self.queue, asked_by=self.student_four, text="Q4") + + def test_small_queue(self, mock_send): + """ + Ensure a text is not sent when less than 3 questions are in the queue. + """ + + Question.objects.all().first().delete() + Question.objects.all().first().delete() + sendUpNextNotificationTask.s(self.queue.id).apply() + mock_send.assert_not_called() + + def test_should_not_send(self, mock_send): + """ + Ensure a text is not sent when the 3rd question shouldn't be send a notification. + """ + + sendUpNextNotificationTask.s(self.queue.id).apply() + mock_send.assert_not_called() + + def test_not_verified(self, mock_send): + """ + Ensure a text is not sent when the person who asked the 3rd question hasn't verified + (or added) a phone number. + """ + + question = Question.objects.get(asked_by=self.student_three) + question.should_send_up_soon_notification = True + question.save() + sendUpNextNotificationTask.s(self.queue.id).apply() + mock_send.assert_not_called() + + def test_send(self, mock_send): + """ + Send an notification SMS when all criteria is met + """ + + self.student_three.profile.sms_verified = True + phone_number = "+15555555555" + self.student_three.profile.phone_number = phone_number + self.student_three.save() + question = Question.objects.get(asked_by=self.student_three) + question.should_send_up_soon_notification = True + question.save() + sendUpNextNotificationTask.s(self.queue.id).apply() + mock_send.assert_called() + self.assertEqual(1, len(mock_send.mock_calls)) + self.assertEqual(self.student_three, mock_send.call_args[0][0]) + self.assertEqual(self.course, mock_send.call_args[0][1]) diff --git a/backend/tests/ohq/test_views.py b/backend/tests/ohq/test_views.py new file mode 100644 index 00000000..06a4a3b5 --- /dev/null +++ b/backend/tests/ohq/test_views.py @@ -0,0 +1,446 @@ +import json +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone +from djangorestframework_camel_case.util import camelize +from rest_framework.test import APIClient +from schedule.models import Event, Occurrence + +from ohq.models import Course, Membership, MembershipInvite, Question, Queue, Semester +from ohq.serializers import UserPrivateSerializer + + +User = get_user_model() + + +class UserViewTestCase(TestCase): + def setUp(self): + self.client = APIClient() + self.user = User.objects.create(username="user") + self.serializer = UserPrivateSerializer(self.user) + + def test_get_object(self): + self.client.force_authenticate(user=self.user) + response = self.client.get(reverse("ohq:me")) + self.assertEqual(json.loads(response.content), camelize(self.serializer.data)) + + +class ResendNotificationViewTestCase(TestCase): + def setUp(self): + self.client = APIClient() + self.user = User.objects.create(username="user") + self.verification_code = "123456" + self.user.profile.sms_verification_code = self.verification_code + self.user.profile.sms_verification_timestamp = timezone.now() + + def test_resend_fail(self): + """ + Ensure resend fails if sent within 10 minutes. + """ + + self.client.force_authenticate(user=self.user) + response = self.client.post(reverse("ohq:resend")) + self.assertEqual(400, response.status_code) + + def test_resend_success(self): + """ + Ensure resend works if sent after 10 minutes. + """ + + self.user.profile.sms_verification_timestamp = timezone.now() - timedelta(minutes=11) + self.client.force_authenticate(user=self.user) + response = self.client.post(reverse("ohq:resend")) + self.assertEqual(200, response.status_code) + self.assertNotEqual(self.verification_code, self.user.profile.sms_verification_code) + + +class MassInviteTestCase(TestCase): + def setUp(self): + self.client = APIClient() + self.professor = User.objects.create(username="professor", email="professor@example.com") + self.semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + self.course = Course.objects.create( + course_code="000", department="TEST", course_title="Title", semester=self.semester + ) + Membership.objects.create( + course=self.course, user=self.professor, kind=Membership.KIND_PROFESSOR + ) + self.user2 = User.objects.create(username="user2", email="user2@example.com") + + MembershipInvite.objects.create(course=self.course, email="user3@example.com") + + def test_invalid_email(self): + self.client.force_authenticate(user=self.professor) + response = self.client.post( + reverse("ohq:mass-invite", args=[self.course.id]), + data={"emails": "invalidemail", "role": "TA"}, + ) + self.assertEqual(400, response.status_code) + + def test_valid_emails(self): + self.client.force_authenticate(user=self.professor) + emails = "professor@example.com,user2@example.com,user3@example.com,user4@example.com" + response = self.client.post( + reverse("ohq:mass-invite", args=[self.course.id]), + data={"emails": emails, "kind": "TA"}, + ) + + self.assertEqual(201, response.status_code) + + # Correct number of invites and memberships created + content = json.loads(response.content) + self.assertEqual(1, content["membersAdded"]) + self.assertEqual(1, content["invitesSent"]) + + +class QuestionViewTestCase(TestCase): + """ + Tests for the Question ViewSet, especially when it comes to creating questions when the + queue has a quota and getting the number of questions asked within the quota period + """ + + def setUp(self): + self.client = APIClient() + self.semester = Semester.objects.create(year=2020, term=Semester.TERM_FALL) + self.course = Course.objects.create( + course_code="000", department="Test Class", semester=self.semester + ) + self.queue = Queue.objects.create( + name="Queue", + course=self.course, + rate_limit_enabled=True, + rate_limit_length=0, + rate_limit_minutes=20, + rate_limit_questions=1, + ) + self.queue2 = Queue.objects.create( + name="Queue2", + course=self.course, + rate_limit_enabled=True, + rate_limit_length=0, + rate_limit_minutes=15, + rate_limit_questions=2, + ) + self.queue3 = Queue.objects.create( + name="Queue3", + course=self.course, + rate_limit_enabled=True, + rate_limit_length=0, + rate_limit_minutes=15, + rate_limit_questions=2, + ) + self.pin = "AAAAA" + self.pin_queue = Queue.objects.create( + name="Pin Queue", course=self.course, pin_enabled=True, pin=self.pin + ) + self.no_limit_queue = Queue.objects.create(name="No Rate Limit Queue", course=self.course) + self.ta = User.objects.create(username="ta") + self.student = User.objects.create(username="student") + self.student2 = User.objects.create(username="student2") + self.student3 = User.objects.create(username="student3") + Membership.objects.create(course=self.course, user=self.ta, kind=Membership.KIND_TA) + Membership.objects.create( + course=self.course, user=self.student, kind=Membership.KIND_STUDENT + ) + Membership.objects.create( + course=self.course, user=self.student2, kind=Membership.KIND_STUDENT + ) + Membership.objects.create( + course=self.course, user=self.student3, kind=Membership.KIND_STUDENT + ) + self.question = Question.objects.create( + queue=self.queue, asked_by=self.student, text="Help me" + ) + self.question.time_asked = timezone.now() - timedelta(days=1) + self.question.save() + + self.old_question = Question.objects.create( + queue=self.queue, + asked_by=self.student, + text="Help me", + time_responded_to=timezone.now() - timedelta(days=1), + time_response_started=timezone.now() - timedelta(days=1), + responded_to_by=self.ta, + status=Question.STATUS_ANSWERED, + ) + self.old_question.time_asked = timezone.now() - timedelta(days=1) + self.old_question.save() + self.rejected_question = Question.objects.create( + queue=self.queue, + asked_by=self.student, + text="Help me", + time_responded_to=timezone.now(), + time_response_started=timezone.now(), + responded_to_by=self.ta, + status=Question.STATUS_REJECTED, + ) + + self.prelimit_question = Question.objects.create( + queue=self.queue2, asked_by=self.student3, text="Help me" + ) + self.prelimit_question1 = Question.objects.create( + queue=self.queue2, asked_by=self.student3, text="Help me" + ) + self.prelimit_question2 = Question.objects.create( + queue=self.queue2, asked_by=self.student3, text="Help me" + ) + + self.prelimit_question.time_responded_to = timezone.now() - timedelta(minutes=3) + self.prelimit_question1.time_responded_to = timezone.now() - timedelta(minutes=6) + self.prelimit_question.save() + self.prelimit_question1.save() + + Question.objects.create(queue=self.queue3, asked_by=self.student3, text="Help me") + + def test_rate_limit(self): + self.client.force_authenticate(user=self.student) + self.client.patch( + reverse("ohq:question-detail", args=[self.course.id, self.queue.id, self.question.id]), + {"status": Question.STATUS_ANSWERED}, + ) + self.assertEqual(7, Question.objects.all().count()) + + res = self.client.post( + reverse("ohq:question-list", args=[self.course.id, self.queue.id]), + {"text": "Should be rate limited", "tags": []}, + ) + self.assertEqual(429, res.status_code) + self.assertEqual(7, Question.objects.all().count()) + + res = self.client.get( + reverse("ohq:question-quota-count", args=[self.course.id, self.queue.id]) + ) + self.assertEqual(1, json.loads(res.content)["count"]) + + res = self.client.get( + reverse("ohq:question-quota-count", args=[self.course.id, self.no_limit_queue.id]) + ) + self.assertEqual(405, res.status_code) + + self.client.force_authenticate(user=self.student2) + self.client.post( + reverse("ohq:question-list", args=[self.course.id, self.queue.id]), + {"text": "Shouldn't be rate limited", "tags": []}, + ) + self.assertEqual(8, Question.objects.all().count()) + + self.client.force_authenticate(user=self.student3) + res = self.client.get( + reverse("ohq:question-quota-count", args=[self.course.id, self.queue2.id]) + ) + self.assertEqual(9, json.loads(res.content)["wait_time_mins"]) + + res = self.client.get( + reverse("ohq:question-quota-count", args=[self.course.id, self.queue3.id]) + ) + self.assertEqual(0, json.loads(res.content)["wait_time_mins"]) + + def test_create_with_pin(self): + self.client.force_authenticate(user=self.student) + Question.objects.all().delete() + + # correct pin is required in queues with pin_enabled + self.client.post( + reverse("ohq:question-list", args=[self.course.id, self.pin_queue.id]), + {"text": "Help me", "tags": []}, + ) + self.assertEqual(0, Question.objects.all().count()) + + self.client.post( + reverse("ohq:question-list", args=[self.course.id, self.pin_queue.id]), + {"text": "Help me", "tags": [], "pin": "BBBBB"}, + ) + self.assertEqual(0, Question.objects.all().count()) + + self.client.post( + reverse("ohq:question-list", args=[self.course.id, self.pin_queue.id]), + {"text": "Help me", "tags": [], "pin": self.pin}, + ) + self.assertEqual(1, Question.objects.all().count()) + + # queue without pin enabled does not require pin in post data + self.client.post( + reverse("ohq:question-list", args=[self.course.id, self.no_limit_queue.id]), + {"text": "Help me", "tags": []}, + ) + self.assertEqual(2, Question.objects.all().count()) + + +class OccurrenceViewTestCase(TestCase): + def setUp(self): + self.client = APIClient() + self.semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER) + self.course = Course.objects.create( + course_code="000", department="Penn Labs", semester=self.semester + ) + self.head_ta = User.objects.create(username="head_ta") + self.ta = User.objects.create(username="ta") + self.student = User.objects.create(username="student") + Membership.objects.create( + course=self.course, user=self.head_ta, kind=Membership.KIND_HEAD_TA + ) + Membership.objects.create(course=self.course, user=self.ta, kind=Membership.KIND_TA) + Membership.objects.create( + course=self.course, user=self.student, kind=Membership.KIND_STUDENT + ) + self.title = "TA session" + self.start_time = "2021-12-05T12:41:37Z" + self.end_time = "2021-12-06T12:41:37Z" + self.end_recurring_period = "2022-12-05T12:41:37Z" + self.filter_start = "2021-12-05T12:40:37Z" + self.filter_end = "2021-12-12T12:42:37Z" + + def test_list(self): + self.client.force_authenticate(user=self.ta) + self.client.post( + reverse("ohq:event-list"), + { + "start": self.start_time, + "end": self.end_time, + "title": self.title, + "rule": {"frequency": "WEEKLY"}, + "end_recurring_period": self.end_recurring_period, + "course_id": self.course.id, + }, + ) + response = self.client.get( + "/api/occurrences/?course=" + + str(self.course.id) + + "&filter_start=" + + self.filter_start + + "&filter_end=" + + self.filter_end + ) + occurrences = json.loads(response.content) + self.assertEquals(2, len(occurrences)) + + def test_cancel(self): + self.client.force_authenticate(user=self.ta) + self.client.post( + reverse("ohq:event-list"), + { + "start": self.start_time, + "end": self.end_time, + "title": self.title, + "end_recurring_period": self.end_recurring_period, + "course_id": self.course.id, + }, + ) + event = Event.objects.all().first() + # create at least one occurrence + self.client.get( + "/api/occurrences/?course=" + + str(self.course.id) + + "&filter_start=" + + self.filter_start + + "&filter_end=" + + self.filter_end + ) + occurrence = Occurrence.objects.all().first() + self.client.patch( + reverse("ohq:occurrence-detail", args=[occurrence.id]), + { + "event": event.id, + "start": occurrence.start, + "end": occurrence.end, + "cancelled": True, + }, + ) + occurrence = event.get_occurrences(event.start - timedelta(1), event.start + timedelta(1))[ + 0 + ] + self.assertTrue(occurrence.cancelled) + + def test_no_rule(self): + # events without rule occur only once + self.client.force_authenticate(user=self.ta) + self.client.post( + reverse("ohq:event-list"), + { + "start": self.start_time, + "end": self.end_time, + "title": self.title, + "end_recurring_period": self.end_recurring_period, + "course_id": self.course.id, + }, + ) + response = self.client.get( + "/api/occurrences/?course=" + + str(self.course.id) + + "&filter_start=" + + self.filter_start + + "&filter_end=" + + self.filter_end + ) + occurrences = json.loads(response.content) + self.assertEquals(1, len(occurrences)) + + # calling twice doesn't create more occurrences + response = self.client.get( + "/api/occurrences/?course=" + + str(self.course.id) + + "&filter_start=" + + self.filter_start + + "&filter_end=" + + self.filter_end + ) + occurrences = json.loads(response.content) + self.assertEquals(1, len(occurrences)) + cnt = Occurrence.objects.all().count() + self.assertEquals(1, cnt) + + def test_update_start(self): + self.client.force_authenticate(user=self.ta) + self.client.post( + reverse("ohq:event-list"), + { + "start": self.start_time, + "end": self.end_time, + "title": self.title, + "rule": {"frequency": "WEEKLY"}, + "end_recurring_period": self.end_recurring_period, + "course_id": self.course.id, + }, + ) + filter_start = "2021-12-04T12:40:37Z" + filter_end = "2021-12-13T12:40:37Z" + response = self.client.get( + "/api/occurrences/?course=" + + str(self.course.id) + + "&filter_start=" + + filter_start + + "&filter_end=" + + filter_end + ) + occurrences = json.loads(response.content) + self.assertEquals(2, len(occurrences)) + self.assertEquals(occurrences[0]["start"], self.start_time) + # update event's start day should update occurrences + event = Event.objects.all().first() + new_start_date = "2021-12-07T12:40:37Z" + new_end_date = "2021-12-08T12:40:37Z" + response = self.client.patch( + reverse("ohq:event-detail", args=[event.id]), + { + "title": "New TA Session", + "course_id": self.course.id, + "start": new_start_date, + "end": new_end_date, + }, + ) + event = Event.objects.all().first() + self.assertEquals(event.title, "New TA Session") + response = self.client.get( + "/api/occurrences/?course=" + + str(self.course.id) + + "&filter_start=" + + filter_start + + "&filter_end=" + + filter_end + ) + occurrences = json.loads(response.content) + self.assertEquals(1, len(occurrences)) + self.assertEquals(occurrences[0]["start"], new_start_date) From 29920b266f7e3abd6337c35d1148c5074f8c978d Mon Sep 17 00:00:00 2001 From: Joy Liu Date: Mon, 6 Feb 2023 22:29:58 -0500 Subject: [PATCH 03/11] :tada: Added frontend for OHQ --- .gitignore | 36 + frontend/.dockerignore | 16 + frontend/.editorconfig | 5 + frontend/.eslintrc.json | 55 + frontend/.prettierrc.json | 4 + frontend/.projectile | 0 frontend/Dockerfile | 23 + frontend/components/Auth/AuthPrompt.tsx | 61 + .../components/Changelog/changelogfile.md | 13 + frontend/components/Changelog/index.tsx | 138 + .../components/Course/Analytics/Analytics.tsx | 57 + .../Course/Analytics/Cards/AnalyticsCard.tsx | 32 + .../Course/Analytics/Cards/SummaryCards.tsx | 121 + .../Course/Analytics/Heatmaps/Averages.tsx | 66 + .../Course/Analytics/Heatmaps/Heatmap.tsx | 85 + .../components/Course/Analytics/mockData.tsx | 234 + frontend/components/Course/Announcements.tsx | 374 ++ .../CourseSettings/CourseForm.module.css | 4 + .../Course/CourseSettings/CourseForm.tsx | 384 ++ .../Course/CourseSettings/CourseSettings.tsx | 39 + .../Course/CourseSidebarInstructorList.tsx | 67 + .../components/Course/CourseSidebarNav.tsx | 133 + frontend/components/Course/CourseWrapper.tsx | 175 + .../InstructorQueuePage/ClearQueueModal.tsx | 63 + .../CreateQueue/CreateQueue.tsx | 333 + .../InstructorQueuePage.tsx | 170 + .../InstructorQueuePage/InstructorQueues.tsx | 162 + .../MessageQuestionModal.tsx | 73 + .../InstructorQueuePage/QuestionCard.tsx | 386 ++ .../Course/InstructorQueuePage/Questions.tsx | 58 + .../Course/InstructorQueuePage/Queue.tsx | 259 + .../InstructorQueuePage/QueueMenuItem.tsx | 105 + .../Course/InstructorQueuePage/QueuePin.tsx | 117 + .../QueueSettings/QueueForm.tsx | 445 ++ .../QueueSettings/QueueSettings.tsx | 66 + .../RejectQuestionModal.tsx | 132 + .../Course/InstructorQueuePage/Tags.tsx | 53 + .../Course/InstructorQueuePage/aol.mp3 | Bin 0 -> 25077 bytes .../InstructorQueuePage/notification.mp3 | Bin 0 -> 77740 bytes .../Course/Roster/ChangeRoleDropdown.tsx | 43 + .../Course/Roster/Invites/AddForm.tsx | 43 + .../Course/Roster/Invites/InviteModal.tsx | 86 + .../components/Course/Roster/RemoveButton.tsx | 74 + frontend/components/Course/Roster/Roster.tsx | 494 ++ .../components/Course/Roster/RosterForm.tsx | 84 + .../StudentQueuePage/DeleteQuestionModal.tsx | 71 + .../StudentQueuePage/EditQuestionModal.tsx | 236 + .../StudentQueuePage/LastQuestionCard.tsx | 98 + .../Course/StudentQueuePage/QuestionCard.tsx | 234 + .../Course/StudentQueuePage/QuestionForm.tsx | 235 + .../Course/StudentQueuePage/QueueMenuItem.tsx | 95 + .../Course/StudentQueuePage/StudentQueue.tsx | 280 + .../StudentQueuePage/StudentQueuePage.tsx | 75 + .../Course/StudentQueuePage/StudentQueues.tsx | 98 + .../components/Course/Summary/Summary.tsx | 163 + .../components/Course/Summary/SummaryForm.tsx | 123 + frontend/components/Guide/InstructorGuide.tsx | 72 + .../Guide/InstructorGuideContent.tsx | 291 + frontend/components/Guide/StudentGuide.tsx | 65 + .../components/Guide/StudentGuideContent.tsx | 165 + frontend/components/Guide/index.tsx | 46 + frontend/components/Guide/utils.tsx | 54 + .../Home/AccountSettings/AccountForm.tsx | 224 + .../Home/AccountSettings/AccountSettings.tsx | 21 + .../Home/AccountSettings/VerificationForm.tsx | 118 + .../VerificationModal.module.css | 5 + .../AccountSettings/VerificationModal.tsx | 30 + .../Home/Dashboard/Cards/AddCard.tsx | 52 + .../Dashboard/Cards/ArchivedCourseCard.tsx | 62 + .../Home/Dashboard/Cards/CourseCard.tsx | 103 + .../components/Home/Dashboard/Dashboard.tsx | 178 + .../Home/Dashboard/Forms/AddStudentForm.tsx | 65 + .../Home/Dashboard/Forms/CreateCourseForm.tsx | 156 + .../Home/Dashboard/InstructorCourses.tsx | 111 + .../Modals/ModalAddInstructorCourse.tsx | 90 + .../Modals/ModalAddStudentCourse.tsx | 71 + .../Modals/ModalLeaveStudentCourse.tsx | 47 + .../Modals/ModalRedirectAddCourse.tsx | 51 + .../Dashboard/Modals/ModalShowNewChanges.tsx | 24 + .../Home/Dashboard/StudentCourses.tsx | 93 + frontend/components/Home/Home.tsx | 13 + frontend/components/Home/HomeSidebar.tsx | 85 + frontend/components/SignOut/index.tsx | 21 + frontend/components/common/AboutModal.tsx | 46 + frontend/components/common/Feedback.tsx | 16 + frontend/components/common/Footer.tsx | 56 + frontend/components/common/ui/LinkedText.tsx | 30 + .../common/ui/ResponsiveIconButton.tsx | 34 + frontend/constants.ts | 29 + frontend/context/auth.tsx | 58 + frontend/csrf.ts | 22 + frontend/global.d.ts | 2 + frontend/hooks/data-fetching/account.ts | 66 + frontend/hooks/data-fetching/analytics.ts | 107 + frontend/hooks/data-fetching/course.ts | 366 ++ frontend/hooks/data-fetching/dashboard.ts | 59 + .../hooks/data-fetching/questionsummary.ts | 98 + frontend/hooks/data-fetching/resources.ts | 50 + frontend/hooks/debounce.ts | 21 + frontend/hooks/player.ts | 33 + frontend/next-env.d.ts | 6 + frontend/next.config.js | 28 + frontend/package.json | 86 + frontend/pages/_app.tsx | 37 + frontend/pages/_error.tsx | 39 + frontend/pages/changelog.tsx | 19 + frontend/pages/courses/[course]/analytics.tsx | 74 + frontend/pages/courses/[course]/index.tsx | 158 + frontend/pages/courses/[course]/roster.tsx | 84 + frontend/pages/courses/[course]/settings.tsx | 80 + frontend/pages/courses/[course]/summary.tsx | 80 + frontend/pages/faq.tsx | 18 + frontend/pages/index.tsx | 80 + frontend/pages/settings.tsx | 19 + frontend/public/answer-queue-1.png | Bin 0 -> 38988 bytes frontend/public/create-course-1.png | Bin 0 -> 28002 bytes frontend/public/create-course-2.png | Bin 0 -> 29135 bytes frontend/public/create-course-3.png | Bin 0 -> 29135 bytes frontend/public/favicon.ico | Bin 0 -> 16446 bytes frontend/public/invite-members-1.png | Bin 0 -> 29446 bytes frontend/public/join-course-1.png | Bin 0 -> 59169 bytes frontend/public/join-course-2.png | Bin 0 -> 77952 bytes frontend/public/joining-oh-1.png | Bin 0 -> 200409 bytes frontend/public/notifications-1.png | Bin 0 -> 30706 bytes frontend/public/ohq-login.png | Bin 0 -> 70598 bytes frontend/public/ohq.png | Bin 0 -> 39758 bytes frontend/public/open-queue-1.png | Bin 0 -> 35006 bytes frontend/public/open-queue-2.png | Bin 0 -> 34733 bytes frontend/public/put-queue-1.png | Bin 0 -> 43823 bytes frontend/public/remove-queue-1.png | Bin 0 -> 44472 bytes frontend/public/vercel.svg | 4 + frontend/public/while-in-queue-1.png | Bin 0 -> 93159 bytes frontend/server.js | 58 + frontend/styles/index.css | 23 + frontend/styles/landingpage.module.css | 30 + frontend/tsconfig.json | 30 + frontend/types.tsx | 199 + frontend/utils/enums.tsx | 90 + frontend/utils/fetch.tsx | 93 + frontend/utils/ga/googleAnalytics.ts | 16 + frontend/utils/ga/withGA.tsx | 22 + frontend/utils/gippage.ts | 5 + frontend/utils/index.tsx | 54 + frontend/utils/notifications.ts | 33 + frontend/utils/protectpage.tsx | 29 + frontend/utils/redirect.ts | 20 + frontend/utils/sentry.tsx | 28 + frontend/utils/staffcheck.ts | 37 + frontend/yarn.lock | 5719 +++++++++++++++++ 149 files changed, 17780 insertions(+) create mode 100644 .gitignore create mode 100644 frontend/.dockerignore create mode 100644 frontend/.editorconfig create mode 100644 frontend/.eslintrc.json create mode 100644 frontend/.prettierrc.json create mode 100644 frontend/.projectile create mode 100644 frontend/Dockerfile create mode 100644 frontend/components/Auth/AuthPrompt.tsx create mode 100644 frontend/components/Changelog/changelogfile.md create mode 100644 frontend/components/Changelog/index.tsx create mode 100644 frontend/components/Course/Analytics/Analytics.tsx create mode 100644 frontend/components/Course/Analytics/Cards/AnalyticsCard.tsx create mode 100644 frontend/components/Course/Analytics/Cards/SummaryCards.tsx create mode 100644 frontend/components/Course/Analytics/Heatmaps/Averages.tsx create mode 100644 frontend/components/Course/Analytics/Heatmaps/Heatmap.tsx create mode 100644 frontend/components/Course/Analytics/mockData.tsx create mode 100644 frontend/components/Course/Announcements.tsx create mode 100644 frontend/components/Course/CourseSettings/CourseForm.module.css create mode 100644 frontend/components/Course/CourseSettings/CourseForm.tsx create mode 100644 frontend/components/Course/CourseSettings/CourseSettings.tsx create mode 100644 frontend/components/Course/CourseSidebarInstructorList.tsx create mode 100644 frontend/components/Course/CourseSidebarNav.tsx create mode 100644 frontend/components/Course/CourseWrapper.tsx create mode 100644 frontend/components/Course/InstructorQueuePage/ClearQueueModal.tsx create mode 100644 frontend/components/Course/InstructorQueuePage/CreateQueue/CreateQueue.tsx create mode 100644 frontend/components/Course/InstructorQueuePage/InstructorQueuePage.tsx create mode 100644 frontend/components/Course/InstructorQueuePage/InstructorQueues.tsx create mode 100644 frontend/components/Course/InstructorQueuePage/MessageQuestionModal.tsx create mode 100644 frontend/components/Course/InstructorQueuePage/QuestionCard.tsx create mode 100644 frontend/components/Course/InstructorQueuePage/Questions.tsx create mode 100644 frontend/components/Course/InstructorQueuePage/Queue.tsx create mode 100644 frontend/components/Course/InstructorQueuePage/QueueMenuItem.tsx create mode 100644 frontend/components/Course/InstructorQueuePage/QueuePin.tsx create mode 100644 frontend/components/Course/InstructorQueuePage/QueueSettings/QueueForm.tsx create mode 100644 frontend/components/Course/InstructorQueuePage/QueueSettings/QueueSettings.tsx create mode 100644 frontend/components/Course/InstructorQueuePage/RejectQuestionModal.tsx create mode 100644 frontend/components/Course/InstructorQueuePage/Tags.tsx create mode 100644 frontend/components/Course/InstructorQueuePage/aol.mp3 create mode 100644 frontend/components/Course/InstructorQueuePage/notification.mp3 create mode 100644 frontend/components/Course/Roster/ChangeRoleDropdown.tsx create mode 100644 frontend/components/Course/Roster/Invites/AddForm.tsx create mode 100644 frontend/components/Course/Roster/Invites/InviteModal.tsx create mode 100644 frontend/components/Course/Roster/RemoveButton.tsx create mode 100644 frontend/components/Course/Roster/Roster.tsx create mode 100644 frontend/components/Course/Roster/RosterForm.tsx create mode 100644 frontend/components/Course/StudentQueuePage/DeleteQuestionModal.tsx create mode 100644 frontend/components/Course/StudentQueuePage/EditQuestionModal.tsx create mode 100644 frontend/components/Course/StudentQueuePage/LastQuestionCard.tsx create mode 100644 frontend/components/Course/StudentQueuePage/QuestionCard.tsx create mode 100644 frontend/components/Course/StudentQueuePage/QuestionForm.tsx create mode 100644 frontend/components/Course/StudentQueuePage/QueueMenuItem.tsx create mode 100644 frontend/components/Course/StudentQueuePage/StudentQueue.tsx create mode 100644 frontend/components/Course/StudentQueuePage/StudentQueuePage.tsx create mode 100644 frontend/components/Course/StudentQueuePage/StudentQueues.tsx create mode 100644 frontend/components/Course/Summary/Summary.tsx create mode 100644 frontend/components/Course/Summary/SummaryForm.tsx create mode 100644 frontend/components/Guide/InstructorGuide.tsx create mode 100644 frontend/components/Guide/InstructorGuideContent.tsx create mode 100644 frontend/components/Guide/StudentGuide.tsx create mode 100644 frontend/components/Guide/StudentGuideContent.tsx create mode 100644 frontend/components/Guide/index.tsx create mode 100644 frontend/components/Guide/utils.tsx create mode 100644 frontend/components/Home/AccountSettings/AccountForm.tsx create mode 100644 frontend/components/Home/AccountSettings/AccountSettings.tsx create mode 100644 frontend/components/Home/AccountSettings/VerificationForm.tsx create mode 100644 frontend/components/Home/AccountSettings/VerificationModal.module.css create mode 100644 frontend/components/Home/AccountSettings/VerificationModal.tsx create mode 100644 frontend/components/Home/Dashboard/Cards/AddCard.tsx create mode 100644 frontend/components/Home/Dashboard/Cards/ArchivedCourseCard.tsx create mode 100644 frontend/components/Home/Dashboard/Cards/CourseCard.tsx create mode 100644 frontend/components/Home/Dashboard/Dashboard.tsx create mode 100644 frontend/components/Home/Dashboard/Forms/AddStudentForm.tsx create mode 100644 frontend/components/Home/Dashboard/Forms/CreateCourseForm.tsx create mode 100644 frontend/components/Home/Dashboard/InstructorCourses.tsx create mode 100644 frontend/components/Home/Dashboard/Modals/ModalAddInstructorCourse.tsx create mode 100644 frontend/components/Home/Dashboard/Modals/ModalAddStudentCourse.tsx create mode 100644 frontend/components/Home/Dashboard/Modals/ModalLeaveStudentCourse.tsx create mode 100644 frontend/components/Home/Dashboard/Modals/ModalRedirectAddCourse.tsx create mode 100644 frontend/components/Home/Dashboard/Modals/ModalShowNewChanges.tsx create mode 100644 frontend/components/Home/Dashboard/StudentCourses.tsx create mode 100644 frontend/components/Home/Home.tsx create mode 100644 frontend/components/Home/HomeSidebar.tsx create mode 100644 frontend/components/SignOut/index.tsx create mode 100644 frontend/components/common/AboutModal.tsx create mode 100644 frontend/components/common/Feedback.tsx create mode 100644 frontend/components/common/Footer.tsx create mode 100644 frontend/components/common/ui/LinkedText.tsx create mode 100644 frontend/components/common/ui/ResponsiveIconButton.tsx create mode 100644 frontend/constants.ts create mode 100644 frontend/context/auth.tsx create mode 100644 frontend/csrf.ts create mode 100644 frontend/global.d.ts create mode 100644 frontend/hooks/data-fetching/account.ts create mode 100644 frontend/hooks/data-fetching/analytics.ts create mode 100644 frontend/hooks/data-fetching/course.ts create mode 100644 frontend/hooks/data-fetching/dashboard.ts create mode 100644 frontend/hooks/data-fetching/questionsummary.ts create mode 100644 frontend/hooks/data-fetching/resources.ts create mode 100644 frontend/hooks/debounce.ts create mode 100644 frontend/hooks/player.ts create mode 100644 frontend/next-env.d.ts create mode 100644 frontend/next.config.js create mode 100644 frontend/package.json create mode 100644 frontend/pages/_app.tsx create mode 100644 frontend/pages/_error.tsx create mode 100644 frontend/pages/changelog.tsx create mode 100644 frontend/pages/courses/[course]/analytics.tsx create mode 100644 frontend/pages/courses/[course]/index.tsx create mode 100644 frontend/pages/courses/[course]/roster.tsx create mode 100644 frontend/pages/courses/[course]/settings.tsx create mode 100644 frontend/pages/courses/[course]/summary.tsx create mode 100644 frontend/pages/faq.tsx create mode 100644 frontend/pages/index.tsx create mode 100644 frontend/pages/settings.tsx create mode 100644 frontend/public/answer-queue-1.png create mode 100644 frontend/public/create-course-1.png create mode 100644 frontend/public/create-course-2.png create mode 100644 frontend/public/create-course-3.png create mode 100644 frontend/public/favicon.ico create mode 100644 frontend/public/invite-members-1.png create mode 100644 frontend/public/join-course-1.png create mode 100644 frontend/public/join-course-2.png create mode 100644 frontend/public/joining-oh-1.png create mode 100644 frontend/public/notifications-1.png create mode 100644 frontend/public/ohq-login.png create mode 100644 frontend/public/ohq.png create mode 100644 frontend/public/open-queue-1.png create mode 100644 frontend/public/open-queue-2.png create mode 100644 frontend/public/put-queue-1.png create mode 100644 frontend/public/remove-queue-1.png create mode 100644 frontend/public/vercel.svg create mode 100644 frontend/public/while-in-queue-1.png create mode 100644 frontend/server.js create mode 100644 frontend/styles/index.css create mode 100644 frontend/styles/landingpage.module.css create mode 100644 frontend/tsconfig.json create mode 100644 frontend/types.tsx create mode 100644 frontend/utils/enums.tsx create mode 100644 frontend/utils/fetch.tsx create mode 100644 frontend/utils/ga/googleAnalytics.ts create mode 100644 frontend/utils/ga/withGA.tsx create mode 100644 frontend/utils/gippage.ts create mode 100644 frontend/utils/index.tsx create mode 100644 frontend/utils/notifications.ts create mode 100644 frontend/utils/protectpage.tsx create mode 100644 frontend/utils/redirect.ts create mode 100644 frontend/utils/sentry.tsx create mode 100644 frontend/utils/staffcheck.ts create mode 100644 frontend/yarn.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..563cf1be --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +frontend/package-lock.json +frontend/yarn-error.log + +# IDE files +.vscode/ +.idea/ + +# Python files +__pycache__/ +*.pyc + +# Distribution +build/ +dist/ +*.egg-info/ + +# Code testing/coverage +.tox +test-results/ +.coverage +htmlcov/ + +# Test database +db.sqlite3 +postgres + +# Mac +.DS_Store + +# React +node_modules/ +.next/ +.log + +# Firebase credentials +ohq-firebase-*.json diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 00000000..570994db --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,16 @@ +# Docker +Dockerfile +.dockerignore + +# git +.circleci +.git +.gitignore +.gitmodules +**/*.md +!components/Changelog/changelogfile.md +LICENSE + +# Misc +node_modules/ +.next/ diff --git a/frontend/.editorconfig b/frontend/.editorconfig new file mode 100644 index 00000000..8d287d59 --- /dev/null +++ b/frontend/.editorconfig @@ -0,0 +1,5 @@ +root = true + +[*.{js, jsx, ts, tsx}] +indent_style = space +indent_size = 4 diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json new file mode 100644 index 00000000..a775a4d5 --- /dev/null +++ b/frontend/.eslintrc.json @@ -0,0 +1,55 @@ +{ + "parser": "@typescript-eslint/parser", + "plugins": ["prettier"], + "extends": ["airbnb", "react-app", "prettier", "prettier/react"], + "env": { + "browser": true + }, + "rules": { + "prettier/prettier": "error", + "import/extensions": 0, + "quotes": ["error", "double", "avoid-escape"], + "no-unused-vars": [ + "error", + { + "args": "none" + } + ], + "import/prefer-default-export": 0, + "react/jsx-filename-extension": 0, + "react/prop-types": 0, + "react/react-in-jsx-scope": 0, + "jsx-a11y/click-events-have-key-events": 0, + "jsx-a11y/interactive-supports-focus": 0, + "react/require-default-props": 0, + "react/jsx-boolean-value": 0, + "no-bitwise": "off", + "no-await-in-loop": "warn", + "no-else-return": 0, + "global-require": 0, + "jsx-a11y/label-has-associated-control": [ + "error", + { + "labelComponents": [], + "labelAttributes": [], + "controlComponents": [ + "AsyncSelect", + "Form.Group", + "Form.Radio", + "Form.Input", + "Form.Dropdown", + "TextField", + "Form.TextArea" + ], + "assert": "either" + } + ] + }, + "settings": { + "import/resolver": { + "node": { + "extensions": [".js", ".jsx", ".ts", ".tsx"] + } + } + } +} diff --git a/frontend/.prettierrc.json b/frontend/.prettierrc.json new file mode 100644 index 00000000..d057b215 --- /dev/null +++ b/frontend/.prettierrc.json @@ -0,0 +1,4 @@ +{ + "tabWidth": 4, + "trailingComma": "es5" +} diff --git a/frontend/.projectile b/frontend/.projectile new file mode 100644 index 00000000..e69de29b diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 00000000..29b4fc45 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,23 @@ +FROM node:14-buster-slim + +LABEL maintainer="Penn Labs" + +WORKDIR /app/ + +# Copy project dependencies +COPY package.json /app/ +COPY yarn.lock /app/ + +# Install project dependencies +RUN yarn install --frozen-lockfile --production=true + +# Copy project files +COPY . /app/ + +# Disable telemetry back to zeit +ENV NEXT_TELEMETRY_DISABLED=1 + +# Build project +RUN yarn build + +CMD ["yarn", "start"] diff --git a/frontend/components/Auth/AuthPrompt.tsx b/frontend/components/Auth/AuthPrompt.tsx new file mode 100644 index 00000000..59fa1473 --- /dev/null +++ b/frontend/components/Auth/AuthPrompt.tsx @@ -0,0 +1,61 @@ +import { useState } from "react"; +import { Grid, Button } from "semantic-ui-react"; +import { useRouter, NextRouter } from "next/router"; +import AboutModal from "../common/AboutModal"; +import styles from "../../styles/landingpage.module.css"; + +const AuthPrompt = (): JSX.Element => { + const [showAboutModal, setShowAboutModal] = useState(false); + const router: NextRouter = useRouter(); + return ( +
+ + + logo + + + logo-mini + + + + +
setShowAboutModal(true)} + > +

About

+
+ setShowAboutModal(false)} + /> +
+
+ ); +}; +export default AuthPrompt; diff --git a/frontend/components/Changelog/changelogfile.md b/frontend/components/Changelog/changelogfile.md new file mode 100644 index 00000000..995dcafd --- /dev/null +++ b/frontend/components/Changelog/changelogfile.md @@ -0,0 +1,13 @@ +## 2022-04-03 +### Added +- New analytics cards listing summary statistics for queues, specifically the number of questions answered, average wait time, number of students helped, and average time helping each student. +### Changed +- The max character limit for course titles has been increased to 100. + +## 2022-02-05 +### Added +- Pin feature that can be turned on and off in queue settings. If selected, generates a random pin upon opening queue that students must input when asking a question. This pin can be changed by instructors. + +## 2021-11-21 +### Added +- Changelog feature to share updates to OHQ. Check here primarily for bug fixes. A more extensive guide on how to use OHQ features can be found in our [FAQ page](faq). diff --git a/frontend/components/Changelog/index.tsx b/frontend/components/Changelog/index.tsx new file mode 100644 index 00000000..2fbb1129 --- /dev/null +++ b/frontend/components/Changelog/index.tsx @@ -0,0 +1,138 @@ +import React, { useEffect, useState } from "react"; +import { unified } from "unified"; +import remarkParse from "remark-parse"; +import rehypeReact from "remark-rehype"; +import rehypeStringify from "rehype-stringify"; +import ReactHtmlParser from "react-html-parser"; + +import { Grid, Checkbox, Header, Segment } from "semantic-ui-react"; +import { diffLines } from "diff"; +import { CHANGELOG_TOKEN } from "../../constants"; +import readIn from "./changelogfile.md"; + +type mdLine = { + content: string; + color: string; +}; + +const processor = unified() + .use(remarkParse) + .use(rehypeReact) + .use(rehypeStringify); + +const TRANSPARENT = "#00000000"; +const GREEN = "#7CFC0070"; +const RED = "#FF000070"; + +export default function Changelog() { + const initial: mdLine = { + content: "LOADING...", + color: TRANSPARENT, + }; + const [mdLine, setMdLine] = useState([initial]); + const [showSlider, setShowSlider] = useState(false); + + useEffect(() => { + const savedMd = + window.localStorage.getItem(CHANGELOG_TOKEN) == null + ? "" + : window.localStorage.getItem(CHANGELOG_TOKEN); + if (readIn !== savedMd) setShowSlider(true); + const diff = diffLines(savedMd, readIn); + const newMd: Array = []; + diff.forEach((part) => { + let lineColor = TRANSPARENT; + if (part.added) { + lineColor = GREEN; + } else if (part.removed) { + lineColor = RED; + } + newMd.push({ + content: part.value, + color: lineColor, + }); + }); + setMdLine(newMd); + window.localStorage.setItem(CHANGELOG_TOKEN, readIn); + }, []); + + const [display, setDisplay] = useState(
); + + const [buttonToggle, setButtonToggle] = useState(true); + + useEffect(() => { + if (buttonToggle) { + setDisplay( + <> + {mdLine.map((part) => ( +
+ <> + {ReactHtmlParser( + processor.processSync(part.content) + )} + +
+ ))} + + ); + } else { + setDisplay( + <> + {mdLine.map( + (part) => + part.color !== RED && ( +
+ <> + {ReactHtmlParser( + processor.processSync(part.content) + )} + +
+ ) + )} + + ); + } + }, [buttonToggle, mdLine]); + return ( + + + +
Changelog
+
+
+ + + {showSlider && ( + + + setButtonToggle( + data.checked ? data.checked : false + ) + } + slider + /> + + )} + {display} + +
+ ); +} + +export function loadInformation() {} diff --git a/frontend/components/Course/Analytics/Analytics.tsx b/frontend/components/Course/Analytics/Analytics.tsx new file mode 100644 index 00000000..093e5173 --- /dev/null +++ b/frontend/components/Course/Analytics/Analytics.tsx @@ -0,0 +1,57 @@ +import Link from "next/link"; +import { useState } from "react"; +import { Segment, Grid, Dropdown } from "semantic-ui-react"; +import { Course, Queue } from "../../../types"; +import Averages from "./Heatmaps/Averages"; +import SummaryCards from "./Cards/SummaryCards"; + +interface AnalyticsProps { + course: Course; + queues: Queue[]; +} + +const Analytics = ({ course, queues }: AnalyticsProps) => { + const [queueId, setQueueId] = useState( + queues.length !== 0 ? queues[0].id : undefined + ); + + const queueOptions = queues.map((queue) => { + return { + key: queue.id, + value: queue.id, + text: queue.name, + }; + }); + + return ( + <> + + {queueId ? ( + <> + { + setQueueId(value as number); + }} + /> + + + + ) : ( + + You have no queues. Create a queue on the{" "} + + queue page + {" "} + to see analytics. + + )} + + + ); +}; + +export default Analytics; diff --git a/frontend/components/Course/Analytics/Cards/AnalyticsCard.tsx b/frontend/components/Course/Analytics/Cards/AnalyticsCard.tsx new file mode 100644 index 00000000..660b6932 --- /dev/null +++ b/frontend/components/Course/Analytics/Cards/AnalyticsCard.tsx @@ -0,0 +1,32 @@ +import { Card, Header } from "semantic-ui-react"; +import React from "react"; + +interface AnalyticsCardProps { + label: string; + content: string; + isValidating: boolean; +} + +export default function AnalyticsCard({ + label, + content, + isValidating, +}: AnalyticsCardProps) { + return ( + + +
{label}
+
+ {isValidating ? "..." : content} +
+
+
+ ); +} diff --git a/frontend/components/Course/Analytics/Cards/SummaryCards.tsx b/frontend/components/Course/Analytics/Cards/SummaryCards.tsx new file mode 100644 index 00000000..426710ac --- /dev/null +++ b/frontend/components/Course/Analytics/Cards/SummaryCards.tsx @@ -0,0 +1,121 @@ +import { Card, Dropdown, Header, Segment } from "semantic-ui-react"; +import React, { useState } from "react"; +import AnalyticsCard from "./AnalyticsCard"; +import { useStatistic } from "../../../../hooks/data-fetching/analytics"; +import { Metric } from "../../../../types"; +import { logException } from "../../../../utils/sentry"; + +interface SummaryCardsProps { + courseId: number; + queueId: number; +} + +export default function SummaryCards({ courseId, queueId }: SummaryCardsProps) { + const [timeRange, setTimeRange] = useState(7); + const timeRangeOptions = [7, 30, 90].map((range) => { + return { + key: range, + value: range, + text: `Last ${range} days`, + }; + }); + + const { + data: numAnsweredData, + isValidating: numAnsweredValidating, + } = useStatistic(courseId, queueId, Metric.NUM_ANSWERED, timeRange); + + const { data: avgWaitData, isValidating: avgWaitValidating } = useStatistic( + courseId, + queueId, + Metric.AVG_WAIT, + timeRange + ); + + const { + data: studentsHelpedData, + // isValidating: studentsHelpedValidating, + } = useStatistic(courseId, queueId, Metric.STUDENTS_HELPED, timeRange); + + const { + data: avgTimeHelpingData, + isValidating: avgTimeHelpingValidating, + } = useStatistic(courseId, queueId, Metric.AVG_TIME_HELPING, timeRange); + + const sum = (data) => { + return data.reduce((prev, cur) => prev + parseInt(cur.value, 10), 0); + }; + + const averageWeighted = (data, weights) => { + let timeHelping = 0; + let numHelped = 0; + + for (const dataItem of data) { + const weightItem = weights.find( + (ele) => dataItem.date === ele.date + ); + if (weightItem) { + timeHelping += + parseInt(dataItem.value, 10) * + parseInt(weightItem.value, 10); + numHelped += parseInt(weightItem.value, 10); + } else { + logException( + Error("Missing weight entry for data item when averaging"), + dataItem.date + ); + } + } + return numHelped ? timeHelping / numHelped : undefined; + }; + + const avgWaitWeighted = averageWeighted(avgWaitData, numAnsweredData); + const avgTimeHelpingWeighted = averageWeighted( + avgTimeHelpingData, + studentsHelpedData + ); + + return ( + +
+
Summary Statistics
+ { + setTimeRange(value as number); + }} + /> +
+ + + {avgWaitWeighted && ( + + )} + {/* */} + {avgTimeHelpingWeighted && ( + + )} + +
+ ); +} diff --git a/frontend/components/Course/Analytics/Heatmaps/Averages.tsx b/frontend/components/Course/Analytics/Heatmaps/Averages.tsx new file mode 100644 index 00000000..5ff01cd0 --- /dev/null +++ b/frontend/components/Course/Analytics/Heatmaps/Averages.tsx @@ -0,0 +1,66 @@ +import { Tab, Segment, Header } from "semantic-ui-react"; +import { useHeatmapData } from "../../../../hooks/data-fetching/analytics"; +import { Metric } from "../../../../types"; +import Heatmap from "./Heatmap"; + +interface AveragesProps { + courseId: number; + queueId: number; +} + +export default function Averages({ courseId, queueId }: AveragesProps) { + const { + data: questionsData, + isValidating: questionsValidating, + } = useHeatmapData(courseId, queueId, Metric.HEATMAP_QUESTIONS); + const { + data: waitTimesData, + isValidating: waitValidating, + } = useHeatmapData(courseId, queueId, Metric.HEATMAP_WAIT); + + return ( + <> + +
Semester Averages
+ { + if (questionsData) { + return ( + + ); + } + if (questionsValidating) { + return
Loading...
; + } + return
Error loading data
; + }, + }, + { + menuItem: "Student Wait Times", + render: () => { + if (waitTimesData) { + return ( + + ); + } + if (waitValidating) + return
Loading...
; + return
Error loading data
; + }, + }, + ]} + /> +
+ + ); +} diff --git a/frontend/components/Course/Analytics/Heatmaps/Heatmap.tsx b/frontend/components/Course/Analytics/Heatmaps/Heatmap.tsx new file mode 100644 index 00000000..cde34aa0 --- /dev/null +++ b/frontend/components/Course/Analytics/Heatmaps/Heatmap.tsx @@ -0,0 +1,85 @@ +import dynamic from "next/dynamic"; +import { HeatmapSeries } from "../../../../types"; + +interface HeatmapProps { + series: HeatmapSeries[]; + chartTitle: string; +} + +// Dynamic import because this library can only run on the browser and causes error when importing server side +const Chart = dynamic(() => import("react-apexcharts"), { ssr: false }); + +const toDisplayHour = (hourString: string) => { + const hourDecimal = Number(hourString); + const hour = Math.trunc(hourDecimal); + const minutes = (hourDecimal % 1) * 60; + + const hourDisplay = hour % 12 !== 0 ? hour % 12 : 12; + const minuteDisplay = minutes !== 0 ? `:${minutes}` : ""; + const amOrPmDisplay = hour < 12 ? "AM" : "PM"; + + return `${hourDisplay}${minuteDisplay} ${amOrPmDisplay}`; +}; + +export default function Heatmap({ series, chartTitle }: HeatmapProps) { + const timeZoneName = Intl.DateTimeFormat().resolvedOptions().timeZone; + + const options = { + dataLabels: { + enabled: false, + }, + colors: ["#2185d0"], + shadeIntensity: 1, + title: { + text: chartTitle, + }, + chart: { + toolbar: { + tools: { + zoom: false, + zoomin: false, + zoomout: false, + pan: false, + reset: false, + download: false, + }, + export: { + csv: { + // TODO: adjust csv export settings to make sure this doesn't break and set download: true + }, + }, + }, + foreColor: "#1B1C1D", + fontFamily: "Lato, 'Helvetica Neue', Arial, Helvetica, sans-serif", + }, + xaxis: { + type: "category", + labels: { + formatter: toDisplayHour, + }, + title: { + text: `Hour (${timeZoneName})`, + }, + }, + responsive: [ + { + breakpoint: 600, + options: {}, + }, + ], + plotOptions: { + heatmap: { + radius: 0, + }, + }, + stroke: { + colors: ["#E5E5E5"], + }, + }; + + return series.length !== 0 ? ( + + ) : ( +
No data available
+ ); +} diff --git a/frontend/components/Course/Analytics/mockData.tsx b/frontend/components/Course/Analytics/mockData.tsx new file mode 100644 index 00000000..fa45b3ec --- /dev/null +++ b/frontend/components/Course/Analytics/mockData.tsx @@ -0,0 +1,234 @@ +// import React from "react"; +// import { Bar } from "react-chartjs-2"; + +// // ---Labels---// +// export const dateLabels = [ +// new Date("December 10, 2019 10:12"), +// new Date("December 18, 2019 20:12"), +// new Date("December 20, 2019 1:03"), +// new Date("December 22, 2019 3:45"), +// new Date("December 25, 2019 14:21"), +// ]; + +// // ---Data---// +// export const DashboardBarChart = () => { +// const data = { +// labels: ["Red", "Blue", "Yellow", "Green", "Purple", "Orange"], +// datasets: [ +// { +// label: "# of Questions", +// data: [12, 19, 3, 5, 2, 3], +// backgroundColor: [ +// "rgba(255, 99, 132, 0.2)", +// "rgba(54, 162, 235, 0.2)", +// "rgba(255, 206, 86, 0.2)", +// "rgba(75, 192, 192, 0.2)", +// "rgba(153, 102, 255, 0.2)", +// "rgba(255, 159, 64, 0.2)", +// ], +// borderColor: [ +// "rgba(255, 99, 132, 1)", +// "rgba(54, 162, 235, 1)", +// "rgba(255, 206, 86, 1)", +// "rgba(75, 192, 192, 1)", +// "rgba(153, 102, 255, 1)", +// "rgba(255, 159, 64, 1)", +// ], +// borderWidth: 1, +// }, +// ], +// options: { +// scales: { +// yAxes: [ +// { +// ticks: { +// beginAtZero: true, +// }, +// }, +// ], +// }, +// }, +// }; +// return ; +// }; + +// export const data4 = { +// labels: ["algorithms", "dijkstra", "dfs", "graphs", "runtime", "sorting"], +// datasets: [ +// { +// label: "# of Questions", +// data: [12, 19, 3, 5, 2, 3], +// backgroundColor: [ +// "rgba(255, 99, 132, 0.2)", +// "rgba(54, 162, 235, 0.2)", +// "rgba(255, 206, 86, 0.2)", +// "rgba(75, 192, 192, 0.2)", +// "rgba(153, 102, 255, 0.2)", +// "rgba(255, 159, 64, 0.2)", +// ], +// borderColor: [ +// "rgba(255, 99, 132, 1)", +// "rgba(54, 162, 235, 1)", +// "rgba(255, 206, 86, 1)", +// "rgba(75, 192, 192, 1)", +// "rgba(153, 102, 255, 1)", +// "rgba(255, 159, 64, 1)", +// ], +// borderWidth: 1, +// }, +// ], +// }; + +// export const options4 = { +// type: "bar", +// data: data4, +// options: { +// scales: { +// yAxes: [ +// { +// ticks: { +// beginAtZero: true, +// }, +// }, +// ], +// }, +// }, +// }; + +// export const data = { +// // Labels should be Date objects +// labels: dateLabels, +// datasets: [ +// { +// fill: false, +// label: "Main Queue", +// data: [20, 11, 15, 3, 10, 8], +// borderColor: "#3e95cd", +// backgroundColor: "#3e95cd", +// lineTension: 0, +// }, +// { +// fill: false, +// label: "Debugging Queue", +// data: [12, 7, 9, 11, 4, 19], +// borderColor: "#3cba9f", +// backgroundColor: "#3cba9f", +// lineTension: 0, +// }, +// ], +// }; + +// export const options = { +// type: "line", +// data, +// options: { +// fill: false, +// responsive: true, +// scales: { +// xAxes: [ +// { +// type: "time", +// time: { +// displayFormats: { +// hour: "MMM D hA", +// }, +// }, +// // display: true, +// scaleLabel: { +// display: true, +// labelString: "Date", +// }, +// }, +// ], +// yAxes: [ +// { +// ticks: { +// beginAtZero: true, +// }, +// display: true, +// scaleLabel: { +// display: true, +// labelString: "Number of Students", +// }, +// }, +// ], +// }, +// }, +// }; + +// export const data2 = { +// // Labels should be Date objects +// labels: dateLabels, +// datasets: [ +// { +// fill: false, +// label: "Page Views", +// data: [280, 250, 340], +// borderColor: "#3e95cd", +// backgroundColor: "#3e95cd", +// lineTension: 0, +// }, +// ], +// }; + +// export const options2 = { +// type: "line", +// data: data2, +// options: { +// fill: false, +// responsive: true, +// scales: { +// xAxes: [ +// { +// type: "time", +// display: true, +// scaleLabel: { +// display: true, +// labelString: "Date", +// }, +// }, +// ], +// yAxes: [ +// { +// ticks: { +// beginAtZero: true, +// }, +// display: true, +// scaleLabel: { +// display: true, +// labelString: "Page Views", +// }, +// }, +// ], +// }, +// }, +// }; + +// export const data3 = { +// labels: ["Homework", "Lecture", "Exam", "Project", "Other"], +// datasets: [ +// { +// label: "Questions by Tag", +// backgroundColor: [ +// "#3e95cd", +// "#8e5ea2", +// "#3cba9f", +// "#e8c3b9", +// "#c45850", +// ], +// data: [2478, 5267, 734, 784, 433], +// }, +// ], +// }; + +// export const options3 = { +// type: "pie", +// data: data3, +// options: { +// title: { +// display: true, +// text: "Tag Categories", +// }, +// }, +// }; +export const dateLabels = []; diff --git a/frontend/components/Course/Announcements.tsx b/frontend/components/Course/Announcements.tsx new file mode 100644 index 00000000..f879f8f5 --- /dev/null +++ b/frontend/components/Course/Announcements.tsx @@ -0,0 +1,374 @@ +import React, { useState, useEffect, useContext } from "react"; +import { + Accordion, + Dropdown, + Form, + Modal, + Message, + Icon, + Button, +} from "semantic-ui-react"; +import { mutateResourceListFunction } from "@pennlabs/rest-hooks/dist/types"; +import { useMediaQuery } from "@material-ui/core"; +import { Announcement, BaseUser, NotificationProps } from "../../types"; +import { AuthUserContext } from "../../context/auth"; +import { + useAnnouncements, + createAnnouncement, +} from "../../hooks/data-fetching/course"; +import ResponsiveIconButton from "../common/ui/ResponsiveIconButton"; +import LinkedText from "../common/ui/LinkedText"; +import { MOBILE_BP } from "../../constants"; + +interface AnnouncementsProps { + courseId: number; + initialAnnouncements: Announcement[]; + staff: boolean; + play: NotificationProps; +} + +interface ModalProps { + announcement: Announcement; + setModalState: (status: ModalState) => void; + mutate: mutateResourceListFunction; +} + +type ModalState = + | { isOpen: true; announcement: Announcement } + | { isOpen: false }; + +const DeleteAnnouncementModal = ({ + announcement, + setModalState, + mutate, +}: ModalProps) => { + return ( + + Delete Announcement + + Are you sure you want to delete this announcement? + + + + + + + ); +}; + +const EditAnnouncementModal = ({ + announcement, + setModalState, + mutate, +}: ModalProps) => { + const [input, setInput] = useState(announcement.content); + return ( + + Edit Announcement + +
+ + + setInput(value as string) + } + /> + +
+
+ + + + Last modified{" "} + {new Date(announcement.timeUpdated).toLocaleString("en-us")} + + + + +
+ ); +}; + +interface NewModalProps { + setModalState: (status: boolean) => void; + mutate: mutateResourceListFunction; + courseId: number; +} + +const NewAnnouncementModal = ({ + setModalState, + mutate, + courseId, +}: NewModalProps) => { + const [input, setInput] = useState(""); + return ( + + New Announcement + +
+ + + setInput(value as string) + } + /> + +
+
+ + + + +
+ ); +}; + +const AnnouncementMessage = ({ + announcement, + staff, + setDeleteState, + setEditState, +}: { + announcement: Announcement; + staff: boolean; + setDeleteState: (state: ModalState) => void; + setEditState: (state: ModalState) => void; +}) => { + const isMobile = useMediaQuery(`(max-width: ${MOBILE_BP})`); + return ( + <> + + {staff && ( + + } + direction="left" + style={{ + position: "absolute", + right: "1rem", + top: "1rem", + }} + > + + { + setEditState({ + isOpen: true, + announcement, + }); + }} + > + + Edit + + + setDeleteState({ + isOpen: true, + announcement, + }) + } + > + + Delete + + + + )} + {!isMobile && } + + {`From ${announcement.author.firstName}`} +

+ +

+
+

+ Posted{" "} + {new Date(announcement.timeUpdated).toLocaleString( + "en-us" + )} +

+
+
+ + ); +}; + +const calcNumUnread = ( + announcements: Announcement[], + latestRead: Date, + user: BaseUser +) => { + let unread = 0; + announcements!.forEach((a) => { + const date = new Date(a.timeUpdated); + if (date > latestRead && a.author.username !== user.username) { + unread += 1; + } + }); + return unread; +}; + +export default function Announcements(props: AnnouncementsProps) { + const { courseId, initialAnnouncements, staff, play } = props; + const { user } = useContext(AuthUserContext); + const { data: announcements, mutate } = useAnnouncements( + courseId, + initialAnnouncements + ); + const [numUnread, setNumUnread] = useState(announcements!.length); + const [latestRead, setLatestRead] = useState(new Date(0)); + + useEffect(() => { + const unread = calcNumUnread(announcements!, latestRead, user!); + setNumUnread(unread); + if (!staff && unread > 0) { + play.current(`You have ${unread} unread announcements`); + } + }, [announcements, latestRead, play, staff, user]); + + const [open, setOpen] = useState(false); + const [newState, setNewState] = useState(false); + const [deleteState, setDeleteState] = useState({ + isOpen: false, + }); + const [editState, setEditState] = useState({ isOpen: false }); + + return ( + <> + {deleteState.isOpen && ( + + )} + {newState && ( + + )} + {editState.isOpen && ( + + )} +
+ + { + setOpen(!open); + let newDate = latestRead; + announcements!.forEach((a) => { + const date = new Date(a.timeUpdated); + if (date > newDate) { + newDate = date; + } + }); + setLatestRead(newDate); + }} + > + + {announcements!.length} Active Announcements ( + {numUnread} Unread) + + {staff && ( + setNewState(true)} + primary + icon={} + desktopProps={{ labelPosition: "left" }} + style={{ + position: "absolute", + right: "0.8rem", + top: "0.5rem", + }} + text="Create New" + /> + )} + + {announcements!.map((a) => ( + + ))} + {announcements!.length === 0 && ( +

+ Nothing to see here +

+ )} +
+
+
+ + ); +} diff --git a/frontend/components/Course/CourseSettings/CourseForm.module.css b/frontend/components/Course/CourseSettings/CourseForm.module.css new file mode 100644 index 00000000..a863848d --- /dev/null +++ b/frontend/components/Course/CourseSettings/CourseForm.module.css @@ -0,0 +1,4 @@ +.department-input input { + text-transform: uppercase; +} + diff --git a/frontend/components/Course/CourseSettings/CourseForm.tsx b/frontend/components/Course/CourseSettings/CourseForm.tsx new file mode 100644 index 00000000..0c24f13c --- /dev/null +++ b/frontend/components/Course/CourseSettings/CourseForm.tsx @@ -0,0 +1,384 @@ +import { useEffect, useState, useMemo } from "react"; +import "./CourseForm.module.css"; + +import { Form, Button, Modal } from "semantic-ui-react"; +import Snackbar from "@material-ui/core/Snackbar"; +import Alert from "@material-ui/lab/Alert"; +import AsyncSelect from "react-select/async"; +import { useRouter } from "next/router"; +import CreatableSelect from "react-select/creatable"; +import { mutateResourceFunction } from "@pennlabs/rest-hooks/dist/types"; +import { Course, Semester, Tag } from "../../../types"; +import { + getSemesters, + createTag, + useTags, +} from "../../../hooks/data-fetching/course"; +import { logException } from "../../../utils/sentry"; +import { COURSE_TITLE_CHAR_LIMIT } from "../../../constants"; + +interface CourseFormProps { + course: Course; + mutateCourse: mutateResourceFunction; + tags: Tag[]; +} + +type TagMap = { + [tag: string]: number; +}; + +const toTagMap = (tags: Tag[]) => + tags.reduce((acc, t) => { + acc[t.name] = t.id; + return acc; + }, {}); + +const CourseForm = (props: CourseFormProps) => { + const { course, mutateCourse, tags: initialTags } = props; + const router = useRouter(); + const [loading, setLoading] = useState(false); + const { data: tags, mutate: mutateTags } = useTags(course.id, initialTags); + + useEffect(() => { + setOldTags(toTagMap(tags!)); + setAddedTags([]); + setDeletedTags([]); + }, [tags]); + + const [input, setInput] = useState({ + inviteOnly: course.inviteOnly, + department: course.department, + courseCode: course.courseCode, + courseTitle: course.courseTitle, + semester: course.semester, + }); + + const [oldTags, setOldTags] = useState(toTagMap(tags!)); + const [tagLabels, setTagLabels] = useState( + tags!.map((t) => t.name) + ); + const [deletedTags, setDeletedTags] = useState([]); + const [addedTags, setAddedTags] = useState([]); + + const [success, setSuccess] = useState(false); + const [error, setError] = useState(false); + const [archiveError, setArchiveError] = useState(false); + const [open, setOpen] = useState(false); + + const [courseTitleCharCount, setCourseTitleCharCount] = useState( + input.courseTitle.length + ); + + const disabled = useMemo( + () => + !input.department || + !input.courseCode || + !input.courseTitle || + !input.semester || + (input.department === course.department && + input.courseCode === course.courseCode && + input.courseTitle === course.courseTitle && + input.inviteOnly === course.inviteOnly && + input.semester === course.semester && + addedTags.length === 0 && + deletedTags.length === 0), + [input, course, addedTags, deletedTags] + ); + + // default recommended tags + const tagOptions = [ + { value: "logistics", label: "logistics" }, + { value: "final", label: "final" }, + ]; + + /* HANDLER FUNCTIONS */ + + const handleInputChange = (e, { name, value }) => { + if (name === "courseTitle") { + if (value.length > COURSE_TITLE_CHAR_LIMIT) { + return; + } + setCourseTitleCharCount(value.length); + } + input[name] = name === "inviteOnly" ? !input[name] : value; + setInput({ ...input }); + }; + + const onSubmit = async () => { + try { + setLoading(true); + await mutateCourse(input); + + const tagPromises: Promise[] = []; + + addedTags.forEach((tag) => { + tagPromises.push(createTag(course.id, tag)); + }); + deletedTags.forEach((tag) => { + tagPromises.push( + mutateTags(oldTags[tag], null, { method: "DELETE" }) + ); + }); + + await Promise.all(tagPromises); + + // revalidate + mutateTags(undefined, undefined, { sendRequest: false }); + + setLoading(false); + setSuccess(true); + } catch (e) { + logException(e); + setLoading(false); + setError(true); + } + }; + + const onArchived = async () => { + try { + setLoading(true); + await mutateCourse({ archived: true }); + setLoading(false); + setOpen(false); + router.replace("/"); + } catch (e) { + logException(e); + setLoading(false); + setArchiveError(true); + } + }; + + const semesterOptions = async (inputValue: string) => { + const semesters: Semester[] = await getSemesters(); + return semesters + .filter( + (semester) => + semester.pretty + .toLowerCase() + .includes(inputValue.toLowerCase()) || + inputValue.length === 0 + ) + .map((semester) => { + return { + label: semester.pretty, + value: semester.id, + }; + }); + }; + + const handleCreateTag = (inputValue: string) => { + if (!(inputValue in oldTags)) { + setAddedTags([...addedTags, inputValue]); + } else { + setDeletedTags( + deletedTags.filter((tag) => { + return tag !== inputValue; + }) + ); + } + + setTagLabels([...tagLabels, inputValue]); + }; + + const handleTagChange = (_, event) => { + if (event.action === "remove-value") { + const text = event.removedValue.label; + + if (text in oldTags) { + setDeletedTags([...deletedTags, text]); + } else { + setAddedTags( + addedTags.filter((tag) => { + return tag !== text; + }) + ); + } + + setTagLabels( + tagLabels.filter((tagLabel) => { + return tagLabel !== text; + }) + ); + } else if (event.action === "clear") { + setTagLabels([]); + setAddedTags([]); + setDeletedTags(Object.keys(oldTags)); + } else if (event.action === "select-option") { + handleCreateTag(event.option.label); + } + }; + + return ( +
+ + + + + + + + + + + +
+ {`Characters: ${courseTitleCharCount}/${COURSE_TITLE_CHAR_LIMIT}`} +
+
+ + + + handleInputChange(undefined, { + name: "semester", + value: id!.value, + }) + } + /> + + + + ({ label: s, value: s }))} + placeholder="Type a tag and press enter..." + onCreateOption={handleCreateTag} + onChange={handleTagChange} + /> + + + + + + + setOpen(true)}> + Archive + + } + > + Archive Course + + You are about to archive{" "} + + {course.department} {course.courseCode} + + . + + +
+ + + )} + + )} + + {render(staff, play, notifs, setNotifs)} + + {!isMobile &&