diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 653c08187..6fae81adf 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,3 +1,3 @@ FROM --platform=linux/amd64 ubuntu:22.04 ARG IMAGE_NAME=pennlabs/courses-devcontainer -RUN apt-get update && apt-get install -y wget curl gcc python3-dev libpq-dev \ No newline at end of file +RUN apt-get update && apt-get install -y wget curl gcc python3-dev libpq-dev postgresql-client \ No newline at end of file diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev deleted file mode 100644 index 75952d067..000000000 --- a/backend/Dockerfile.dev +++ /dev/null @@ -1,14 +0,0 @@ -FROM pennlabs/django-base:f0f05216db7c23c1dbb5b95c3bc9e8a2603bf2fd - -LABEL maintainer="Penn Labs" - -WORKDIR /backend - -# Copy project dependencies -COPY Pipfile* ./ - -# Install backend dependencies -RUN pipenv install --dev - -# Alias runserver command -RUN echo 'alias runserver="python manage.py runserver 0.0.0.0:8000"' >> ~/.bashrc diff --git a/backend/PennCourses/docs_settings.py b/backend/PennCourses/docs_settings.py index 43e18a75b..f1377d64d 100644 --- a/backend/PennCourses/docs_settings.py +++ b/backend/PennCourses/docs_settings.py @@ -6,12 +6,15 @@ from textwrap import dedent import jsonref +from django.db import models from django.urls import get_resolver from rest_framework import serializers +from rest_framework.fields import _UnvalidatedField from rest_framework.permissions import IsAuthenticated from rest_framework.renderers import JSONOpenAPIRenderer from rest_framework.schemas.openapi import AutoSchema from rest_framework.schemas.utils import is_list_view +from rest_framework.settings import api_settings """ @@ -353,6 +356,7 @@ def get_url_by_name(name): "review": "PCR", "base": "PCx", "accounts": "Accounts", + "degree": "PDP", } assert all( [isinstance(key, str) and isinstance(val, str) for key, val in subpath_abbreviations.items()] @@ -991,6 +995,7 @@ def map_serializer(self, serializer): """ result = super().map_serializer(serializer) + properties = result["properties"] model = None if hasattr(serializer, "Meta") and hasattr(serializer.Meta, "model"): @@ -1011,6 +1016,130 @@ def map_serializer(self, serializer): return result + # Overrides, uses overridden method + # (https://www.django-rest-framework.org/api-guide/schemas/#map_field) + def map_field(self, field): + + # Nested Serializers, `many` or not. + if isinstance(field, serializers.ListSerializer): + return {"type": "array", "items": []} + if isinstance(field, serializers.Serializer): + data = self.map_serializer(field) + data["type"] = "object" + return data + + # Related fields. + if isinstance(field, serializers.ManyRelatedField): + return {"type": "array", "items": self.map_field(field.child_relation)} + if isinstance(field, serializers.PrimaryKeyRelatedField): + if getattr(field, "pk_field", False): + return self.map_field(field=field.pk_field) + model = getattr(field.queryset, "model", None) + if model is not None: + model_field = model._meta.pk + if isinstance(model_field, models.AutoField): + return {"type": "integer"} + + # ChoiceFields (single and multiple). + # Q: + # - Is 'type' required? + # - can we determine the TYPE of a choicefield? + if isinstance(field, serializers.MultipleChoiceField): + return {"type": "array", "items": self.map_choicefield(field)} + + if isinstance(field, serializers.ChoiceField): + return self.map_choicefield(field) + + # ListField. + if isinstance(field, serializers.ListField): + mapping = { + "type": "array", + "items": {}, + } + if not isinstance(field.child, _UnvalidatedField): + mapping["items"] = self.map_field(field.child) + return mapping + + # DateField and DateTimeField type is string + if isinstance(field, serializers.DateField): + return { + "type": "string", + "format": "date", + } + + if isinstance(field, serializers.DateTimeField): + return { + "type": "string", + "format": "date-time", + } + + # "Formats such as "email", "uuid", and so on, MAY be used even though undefined by this + # specification." + # see: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#data-types + # see also: https://swagger.io/docs/specification/data-models/data-types/#string + if isinstance(field, serializers.EmailField): + return {"type": "string", "format": "email"} + + if isinstance(field, serializers.URLField): + return {"type": "string", "format": "uri"} + + if isinstance(field, serializers.UUIDField): + return {"type": "string", "format": "uuid"} + + if isinstance(field, serializers.IPAddressField): + content = { + "type": "string", + } + if field.protocol != "both": + content["format"] = field.protocol + return content + + if isinstance(field, serializers.DecimalField): + if getattr(field, "coerce_to_string", api_settings.COERCE_DECIMAL_TO_STRING): + content = { + "type": "string", + "format": "decimal", + } + else: + content = {"type": "number"} + + if field.decimal_places: + content["multipleOf"] = float("." + (field.decimal_places - 1) * "0" + "1") + if field.max_whole_digits: + content["maximum"] = int(field.max_whole_digits * "9") + 1 + content["minimum"] = -content["maximum"] + self._map_min_max(field, content) + return content + + if isinstance(field, serializers.FloatField): + content = { + "type": "number", + } + self._map_min_max(field, content) + return content + + if isinstance(field, serializers.IntegerField): + content = {"type": "integer"} + self._map_min_max(field, content) + # 2147483647 is max for int32_size, so we use int64 for format + if int(content.get("maximum", 0)) > 2147483647: + content["format"] = "int64" + if int(content.get("minimum", 0)) > 2147483647: + content["format"] = "int64" + return content + + if isinstance(field, serializers.FileField): + return {"type": "string", "format": "binary"} + + # Simplest cases, default to 'string' type: + FIELD_CLASS_SCHEMA_TYPE = { + serializers.BooleanField: "boolean", + serializers.JSONField: "object", + serializers.DictField: "object", + serializers.HStoreField: "object", + } + return {"type": FIELD_CLASS_SCHEMA_TYPE.get(field.__class__, "string")} + # Helper method def get_action(self, path, method): """ diff --git a/backend/PennCourses/settings/base.py b/backend/PennCourses/settings/base.py index 8b4ad9169..b12601fc2 100644 --- a/backend/PennCourses/settings/base.py +++ b/backend/PennCourses/settings/base.py @@ -238,3 +238,6 @@ # The name of the schedule that is created/verified by Penn Mobile, # containing the user's active course registrations from Path. PATH_REGISTRATION_SCHEDULE_NAME = "Path Registration" + +# Manually Set Cache Prefix +CACHE_PREFIX = "MANUAL_CACHE_" diff --git a/backend/Pipfile.lock b/backend/Pipfile.lock index 9a12ea166..180ed6085 100644 --- a/backend/Pipfile.lock +++ b/backend/Pipfile.lock @@ -221,20 +221,20 @@ }, "boto3": { "hashes": [ - "sha256:9edf49640c79a05b0a72f4c2d1e24dfc164344b680535a645f455ac624dc3680", - "sha256:db58348849a5af061f0f5ec9c3b699da5221ca83354059fdccb798e3ddb6b62a" + "sha256:18416d07b41e6094101a44f8b881047dcec6b846dad0b9f83b9bbf2f0cd93d07", + "sha256:7f8e8a252458d584d8cf7877c372c4f74ec103356eedf43d2dd9e479f47f3639" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.35.57" + "version": "==1.35.44" }, "botocore": { "hashes": [ - "sha256:92ddd02469213766872cb2399269dd20948f90348b42bf08379881d5e946cc34", - "sha256:d96306558085baf0bcb3b022d7a8c39c93494f031edb376694d2b2dcd0e81327" + "sha256:1fcd97b966ad8a88de4106fe1bd3bbd6d8dadabe99bbd4a6aadcf11cb6c66b39", + "sha256:55388e80624401d017a9a2b8109afd94814f7e666b53e28fce51375cfa8d9326" ], "markers": "python_version >= '3.8'", - "version": "==1.35.57" + "version": "==1.35.44" }, "celery": { "hashes": [ @@ -447,11 +447,11 @@ }, "dj-database-url": { "hashes": [ - "sha256:ae52e8e634186b57e5a45e445da5dc407a819c2ceed8a53d1fac004cc5288787", - "sha256:bb0d414ba0ac5cd62773ec7f86f8cc378a9dbb00a80884c2fc08cc570452521e" + "sha256:3e792567b0aa9a4884860af05fe2aa4968071ad351e033b6db632f97ac6db9de", + "sha256:9f9b05058ddf888f1e6f840048b8d705ff9395e3b52a07165daa3d8b9360551b" ], "index": "pypi", - "version": "==2.3.0" + "version": "==2.2.0" }, "django": { "hashes": [ @@ -473,12 +473,12 @@ }, "django-cors-headers": { "hashes": [ - "sha256:14d76b4b4c8d39375baeddd89e4f08899051eeaf177cb02a29bd6eae8cf63aa8", - "sha256:8edbc0497e611c24d5150e0055d3b178c6534b8ed826fb6f53b21c63f5d48ba3" + "sha256:28c1ded847aa70208798de3e42422a782f427b8b720e8d7319d34b654b5978e6", + "sha256:6c01a85cf1ec779a7bde621db853aa3ce5c065a5ba8e27df7a9f9e8dac310f4f" ], "index": "pypi", "markers": "python_version >= '3.9'", - "version": "==4.6.0" + "version": "==4.5.0" }, "django-extensions": { "hashes": [ @@ -760,12 +760,12 @@ }, "ipython": { "hashes": [ - "sha256:0188a1bd83267192123ccea7f4a8ed0a78910535dbaa3f37671dca76ebd429c8", - "sha256:40b60e15b22591450eef73e40a027cf77bd652e757523eebc5bd7c7c498290eb" + "sha256:0d0d15ca1e01faeb868ef56bc7ee5a0de5bd66885735682e8a322ae289a13d1a", + "sha256:530ef1e7bb693724d3cdc37287c80b07ad9b25986c007a53aa1857272dac3f35" ], "index": "pypi", "markers": "python_version >= '3.10'", - "version": "==8.29.0" + "version": "==8.28.0" }, "itypes": { "hashes": [ @@ -1085,65 +1085,63 @@ }, "numpy": { "hashes": [ - "sha256:016d0f6f5e77b0f0d45d77387ffa4bb89816b57c835580c3ce8e099ef830befe", - "sha256:02135ade8b8a84011cbb67dc44e07c58f28575cf9ecf8ab304e51c05528c19f0", - "sha256:08788d27a5fd867a663f6fc753fd7c3ad7e92747efc73c53bca2f19f8bc06f48", - "sha256:0d30c543f02e84e92c4b1f415b7c6b5326cbe45ee7882b6b77db7195fb971e3a", - "sha256:0fa14563cc46422e99daef53d725d0c326e99e468a9320a240affffe87852564", - "sha256:13138eadd4f4da03074851a698ffa7e405f41a0845a6b1ad135b81596e4e9958", - "sha256:14e253bd43fc6b37af4921b10f6add6925878a42a0c5fe83daee390bca80bc17", - "sha256:15cb89f39fa6d0bdfb600ea24b250e5f1a3df23f901f51c8debaa6a5d122b2f0", - "sha256:17ee83a1f4fef3c94d16dc1802b998668b5419362c8a4f4e8a491de1b41cc3ee", - "sha256:2312b2aa89e1f43ecea6da6ea9a810d06aae08321609d8dc0d0eda6d946a541b", - "sha256:2564fbdf2b99b3f815f2107c1bbc93e2de8ee655a69c261363a1172a79a257d4", - "sha256:3522b0dfe983a575e6a9ab3a4a4dfe156c3e428468ff08ce582b9bb6bd1d71d4", - "sha256:4394bc0dbd074b7f9b52024832d16e019decebf86caf909d94f6b3f77a8ee3b6", - "sha256:45966d859916ad02b779706bb43b954281db43e185015df6eb3323120188f9e4", - "sha256:4d1167c53b93f1f5d8a139a742b3c6f4d429b54e74e6b57d0eff40045187b15d", - "sha256:4f2015dfe437dfebbfce7c85c7b53d81ba49e71ba7eadbf1df40c915af75979f", - "sha256:50ca6aba6e163363f132b5c101ba078b8cbd3fa92c7865fd7d4d62d9779ac29f", - "sha256:50d18c4358a0a8a53f12a8ba9d772ab2d460321e6a93d6064fc22443d189853f", - "sha256:5641516794ca9e5f8a4d17bb45446998c6554704d888f86df9b200e66bdcce56", - "sha256:576a1c1d25e9e02ed7fa5477f30a127fe56debd53b8d2c89d5578f9857d03ca9", - "sha256:6a4825252fcc430a182ac4dee5a505053d262c807f8a924603d411f6718b88fd", - "sha256:72dcc4a35a8515d83e76b58fdf8113a5c969ccd505c8a946759b24e3182d1f23", - "sha256:747641635d3d44bcb380d950679462fae44f54b131be347d5ec2bce47d3df9ed", - "sha256:762479be47a4863e261a840e8e01608d124ee1361e48b96916f38b119cfda04a", - "sha256:78574ac2d1a4a02421f25da9559850d59457bac82f2b8d7a44fe83a64f770098", - "sha256:825656d0743699c529c5943554d223c021ff0494ff1442152ce887ef4f7561a1", - "sha256:8637dcd2caa676e475503d1f8fdb327bc495554e10838019651b76d17b98e512", - "sha256:96fe52fcdb9345b7cd82ecd34547fca4321f7656d500eca497eb7ea5a926692f", - "sha256:973faafebaae4c0aaa1a1ca1ce02434554d67e628b8d805e61f874b84e136b09", - "sha256:996bb9399059c5b82f76b53ff8bb686069c05acc94656bb259b1d63d04a9506f", - "sha256:a38c19106902bb19351b83802531fea19dee18e5b37b36454f27f11ff956f7fc", - "sha256:a6b46587b14b888e95e4a24d7b13ae91fa22386c199ee7b418f449032b2fa3b8", - "sha256:a9f7f672a3388133335589cfca93ed468509cb7b93ba3105fce780d04a6576a0", - "sha256:aa08e04e08aaf974d4458def539dece0d28146d866a39da5639596f4921fd761", - "sha256:b0df3635b9c8ef48bd3be5f862cf71b0a4716fa0e702155c45067c6b711ddcef", - "sha256:b47fbb433d3260adcd51eb54f92a2ffbc90a4595f8970ee00e064c644ac788f5", - "sha256:baed7e8d7481bfe0874b566850cb0b85243e982388b7b23348c6db2ee2b2ae8e", - "sha256:bc6f24b3d1ecc1eebfbf5d6051faa49af40b03be1aaa781ebdadcbc090b4539b", - "sha256:c006b607a865b07cd981ccb218a04fc86b600411d83d6fc261357f1c0966755d", - "sha256:c181ba05ce8299c7aa3125c27b9c2167bca4a4445b7ce73d5febc411ca692e43", - "sha256:c7662f0e3673fe4e832fe07b65c50342ea27d989f92c80355658c7f888fcc83c", - "sha256:c80e4a09b3d95b4e1cac08643f1152fa71a0a821a2d4277334c88d54b2219a41", - "sha256:c894b4305373b9c5576d7a12b473702afdf48ce5369c074ba304cc5ad8730dff", - "sha256:d7aac50327da5d208db2eec22eb11e491e3fe13d22653dce51b0f4109101b408", - "sha256:d89dd2b6da69c4fff5e39c28a382199ddedc3a5be5390115608345dec660b9e2", - "sha256:d9beb777a78c331580705326d2367488d5bc473b49a9bc3036c154832520aca9", - "sha256:dc258a761a16daa791081d026f0ed4399b582712e6fc887a95af09df10c5ca57", - "sha256:e14e26956e6f1696070788252dcdff11b4aca4c3e8bd166e0df1bb8f315a67cb", - "sha256:e6988e90fcf617da2b5c78902fe8e668361b43b4fe26dbf2d7b0f8034d4cafb9", - "sha256:e711e02f49e176a01d0349d82cb5f05ba4db7d5e7e0defd026328e5cfb3226d3", - "sha256:ea4dedd6e394a9c180b33c2c872b92f7ce0f8e7ad93e9585312b0c5a04777a4a", - "sha256:ecc76a9ba2911d8d37ac01de72834d8849e55473457558e12995f4cd53e778e0", - "sha256:f55ba01150f52b1027829b50d70ef1dafd9821ea82905b63936668403c3b471e", - "sha256:f653490b33e9c3a4c1c01d41bc2aef08f9475af51146e4a7710c450cf9761598", - "sha256:fa2d1337dc61c8dc417fbccf20f6d1e139896a30721b7f1e832b2bb6ef4eb6c4" + "sha256:05b2d4e667895cc55e3ff2b56077e4c8a5604361fc21a042845ea3ad67465aa8", + "sha256:12edb90831ff481f7ef5f6bc6431a9d74dc0e5ff401559a71e5e4611d4f2d466", + "sha256:13311c2db4c5f7609b462bc0f43d3c465424d25c626d95040f073e30f7570e35", + "sha256:13532a088217fa624c99b843eeb54640de23b3414b14aa66d023805eb731066c", + "sha256:13602b3174432a35b16c4cfb5de9a12d229727c3dd47a6ce35111f2ebdf66ff4", + "sha256:1600068c262af1ca9580a527d43dc9d959b0b1d8e56f8a05d830eea39b7c8af6", + "sha256:1b8cde4f11f0a975d1fd59373b32e2f5a562ade7cde4f85b7137f3de8fbb29a0", + "sha256:1c193d0b0238638e6fc5f10f1b074a6993cb13b0b431f64079a509d63d3aa8b7", + "sha256:1ebec5fd716c5a5b3d8dfcc439be82a8407b7b24b230d0ad28a81b61c2f4659a", + "sha256:242b39d00e4944431a3cd2db2f5377e15b5785920421993770cddb89992c3f3a", + "sha256:259ec80d54999cc34cd1eb8ded513cb053c3bf4829152a2e00de2371bd406f5e", + "sha256:2abbf905a0b568706391ec6fa15161fad0fb5d8b68d73c461b3c1bab6064dd62", + "sha256:2cbba4b30bf31ddbe97f1c7205ef976909a93a66bb1583e983adbd155ba72ac2", + "sha256:2ffef621c14ebb0188a8633348504a35c13680d6da93ab5cb86f4e54b7e922b5", + "sha256:30d53720b726ec36a7f88dc873f0eec8447fbc93d93a8f079dfac2629598d6ee", + "sha256:32e16a03138cabe0cb28e1007ee82264296ac0983714094380b408097a418cfe", + "sha256:43cca367bf94a14aca50b89e9bc2061683116cfe864e56740e083392f533ce7a", + "sha256:456e3b11cb79ac9946c822a56346ec80275eaf2950314b249b512896c0d2505e", + "sha256:4d6ec0d4222e8ffdab1744da2560f07856421b367928026fb540e1945f2eeeaf", + "sha256:5006b13a06e0b38d561fab5ccc37581f23c9511879be7693bd33c7cd15ca227c", + "sha256:675c741d4739af2dc20cd6c6a5c4b7355c728167845e3c6b0e824e4e5d36a6c3", + "sha256:6cdb606a7478f9ad91c6283e238544451e3a95f30fb5467fbf715964341a8a86", + "sha256:6d95f286b8244b3649b477ac066c6906fbb2905f8ac19b170e2175d3d799f4df", + "sha256:76322dcdb16fccf2ac56f99048af32259dcc488d9b7e25b51e5eca5147a3fb98", + "sha256:7c1c60328bd964b53f8b835df69ae8198659e2b9302ff9ebb7de4e5a5994db3d", + "sha256:860ec6e63e2c5c2ee5e9121808145c7bf86c96cca9ad396c0bd3e0f2798ccbe2", + "sha256:8e00ea6fc82e8a804433d3e9cedaa1051a1422cb6e443011590c14d2dea59146", + "sha256:9c6c754df29ce6a89ed23afb25550d1c2d5fdb9901d9c67a16e0b16eaf7e2550", + "sha256:a26ae94658d3ba3781d5e103ac07a876b3e9b29db53f68ed7df432fd033358a8", + "sha256:a65acfdb9c6ebb8368490dbafe83c03c7e277b37e6857f0caeadbbc56e12f4fb", + "sha256:a7d80b2e904faa63068ead63107189164ca443b42dd1930299e0d1cb041cec2e", + "sha256:a84498e0d0a1174f2b3ed769b67b656aa5460c92c9554039e11f20a05650f00d", + "sha256:ab4754d432e3ac42d33a269c8567413bdb541689b02d93788af4131018cbf366", + "sha256:ad369ed238b1959dfbade9018a740fb9392c5ac4f9b5173f420bd4f37ba1f7a0", + "sha256:b1d0fcae4f0949f215d4632be684a539859b295e2d0cb14f78ec231915d644db", + "sha256:b42a1a511c81cc78cbc4539675713bbcf9d9c3913386243ceff0e9429ca892fe", + "sha256:bd33f82e95ba7ad632bc57837ee99dba3d7e006536200c4e9124089e1bf42426", + "sha256:bdd407c40483463898b84490770199d5714dcc9dd9b792f6c6caccc523c00952", + "sha256:c6eef7a2dbd0abfb0d9eaf78b73017dbfd0b54051102ff4e6a7b2980d5ac1a03", + "sha256:c82af4b2ddd2ee72d1fc0c6695048d457e00b3582ccde72d8a1c991b808bb20f", + "sha256:d666cb72687559689e9906197e3bec7b736764df6a2e58ee265e360663e9baf7", + "sha256:d7bf0a4f9f15b32b5ba53147369e94296f5fffb783db5aacc1be15b4bf72f43b", + "sha256:d82075752f40c0ddf57e6e02673a17f6cb0f8eb3f587f63ca1eaab5594da5b17", + "sha256:da65fb46d4cbb75cb417cddf6ba5e7582eb7bb0b47db4b99c9fe5787ce5d91f5", + "sha256:e2b49c3c0804e8ecb05d59af8386ec2f74877f7ca8fd9c1e00be2672e4d399b1", + "sha256:e585c8ae871fd38ac50598f4763d73ec5497b0de9a0ab4ef5b69f01c6a046142", + "sha256:e8d3ca0a72dd8846eb6f7dfe8f19088060fcb76931ed592d29128e0219652884", + "sha256:ef444c57d664d35cac4e18c298c47d7b504c66b17c2ea91312e979fcfbdfb08a", + "sha256:f1eb068ead09f4994dec71c24b2844f1e4e4e013b9629f812f292f04bd1510d9", + "sha256:f2ded8d9b6f68cc26f8425eda5d3877b47343e68ca23d0d0846f4d312ecaa445", + "sha256:f751ed0a2f250541e19dfca9f1eafa31a392c71c832b6bb9e113b10d050cb0f1", + "sha256:faa88bc527d0f097abdc2c663cddf37c05a1c2f113716601555249805cf573f1", + "sha256:fc44e3c68ff00fd991b59092a54350e6e4911152682b4782f68070985aa9e648" ], "index": "pypi", "markers": "python_version >= '3.10'", - "version": "==2.1.3" + "version": "==2.1.2" }, "oauthlib": { "hashes": [ @@ -1155,11 +1153,11 @@ }, "packaging": { "hashes": [ - "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", - "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f" + "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", + "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124" ], "markers": "python_version >= '3.8'", - "version": "==24.2" + "version": "==24.1" }, "pandas": { "hashes": [ @@ -1228,11 +1226,11 @@ }, "phonenumbers": { "hashes": [ - "sha256:e17140955ab3d8f9580727372ea64c5ada5327932d6021ef6fd203c3db8c8139", - "sha256:e608ccb61f0bd42e6db1d2c421f7c22186b88f494870bf40aa31d1a2718ab0ae" + "sha256:53c5e7c6d431cafe4efdd44956078404ae9bc8b0eacc47be3105d3ccc88aaffa", + "sha256:5d3c0142ef7055ca5551884352e3b6b93bfe002a0bc95b8eaba39b0e2184541b" ], "index": "pypi", - "version": "==8.13.49" + "version": "==8.13.47" }, "prompt-toolkit": { "hashes": [ @@ -1477,12 +1475,12 @@ }, "redis": { "hashes": [ - "sha256:0b1087665a771b1ff2e003aa5bdd354f15a70c9e25d5a7dbf9c722c16528a7b0", - "sha256:ae174f2bb3b1bf2b09d54bf3e51fbc1469cf6c10aa03e21141f51969801a7897" + "sha256:f6c997521fedbae53387307c5d0bf784d9acc28d9f1d058abeac566ec4dbed72", + "sha256:f8ea06b7482a668c6475ae202ed8d9bcaa409f6e87fb77ed1043d912afd62e24" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==5.2.0" + "version": "==5.1.1" }, "requests": { "hashes": [ @@ -1583,12 +1581,12 @@ }, "sentry-sdk": { "hashes": [ - "sha256:0dc21febd1ab35c648391c664df96f5f79fb0d92d7d4225cd9832e53a617cafd", - "sha256:ee70e27d1bbe4cd52a38e1bd28a5fadb9b17bc29d91b5f2b97ae29c0a7610442" + "sha256:625955884b862cc58748920f9e21efdfb8e0d4f98cca4ab0d3918576d5b606ad", + "sha256:dd0a05352b78ffeacced73a94e86f38b32e2eae15fff5f30ca5abb568a72eacf" ], "index": "pypi", "markers": "python_version >= '3.6'", - "version": "==2.18.0" + "version": "==2.17.0" }, "shortener": { "hashes": [ @@ -1665,12 +1663,12 @@ }, "tqdm": { "hashes": [ - "sha256:0cd8af9d56911acab92182e88d763100d4788bdf421d251616040cc4d44863be", - "sha256:fe5a6f95e6fe0b9755e9469b77b9c3cf850048224ecaa8293d7d2d31f97d869a" + "sha256:90279a3770753eafc9194a0364852159802111925aa30eb3f9d85b0e805ac7cd", + "sha256:e1020aef2e5096702d8a025ac7d16b1577279c9d63f8375b63083e9a5f0fcbad" ], "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==4.67.0" + "version": "==4.66.5" }, "traitlets": { "hashes": [ @@ -1682,12 +1680,12 @@ }, "twilio": { "hashes": [ - "sha256:c5d7f4cfeb50a7928397b8f819c8f7fb2bb956a1a2cabbda1df1d7a40f9ce1d7", - "sha256:d42691f7fe1faaa5ba82942f169bfea4d7f01a0a542a456d82018fb49bd1f5b2" + "sha256:2cae99f0f7aecbd9da02fa59ad8f11b360db4a9281fc3fb3237ad50be21d8a9b", + "sha256:38a6ab04752f44313dcf736eae45236a901528d3f53dfc21d3afd33539243c7f" ], "index": "pypi", "markers": "python_full_version >= '3.7.0'", - "version": "==9.3.6" + "version": "==9.3.4" }, "types-python-dateutil": { "hashes": [ @@ -1795,9 +1793,9 @@ }, "uwsgi": { "hashes": [ - "sha256:79ca1891ef2df14508ab0471ee8c0eb94bd2d51d03f32f90c4bbe557ab1e99d0" + "sha256:3ee5bfb7e6e9c93478c22aa8183eef35b95a2d5b14cca16172e67f135565c458" ], - "version": "==2.0.28" + "version": "==2.0.27" }, "vine": { "hashes": [ @@ -1904,173 +1902,190 @@ }, "websockets": { "hashes": [ - "sha256:064a72c0602c2d2c2586143561e0f179ef9b98e0825dc4a3d5cdf55a81898ed6", - "sha256:08d62f438a591c016c5d4c79eaf9a8f7a85b6c3ea88793d676c00c930a41e775", - "sha256:0913596e0072202be8729dab05266398b72ee57c4232f48d52fe2a0370d0b53f", - "sha256:0ae0e14729038208711d2e2f769280621c22cd253e3dac00f809fa38c6ccb79d", - "sha256:0b406f2387dbaf301996b7b2cf41519c1fbba7d5c9626406dd56f72075a60a00", - "sha256:0bae3caf386d418e83b62e8c1c4cec1b13348fac43e530b9894d6c7c02d921b5", - "sha256:12c345585b1da70cd27a298b0b9a81aa18da7a690672f771b427db59c632d8aa", - "sha256:176b39547950ff3520728bd1eadd0fa02c68492a1fabca636bab7883dd390905", - "sha256:189e9f074f2a77f7cf54634797b29be28116ee564ece421c7653030a2cef48f0", - "sha256:1a3bca8cfb66614e23a65aa5d6b87190876ec6f3247094939f9db877db55319c", - "sha256:1c4ca7cc5a02f909789dad259dffe61be4f38ffb26dc5e26ab2dca2c7d7c87de", - "sha256:1e0e543e0e81c55e68552bd3c081282721c710a6379a2a78e1ec793853479b25", - "sha256:1e541e4c8983b118a584c306070878e7f9670b7781e04184b6e05f9fc92e8a0e", - "sha256:20979614e4d7266f15018c154255d35dfb9fc828fdf6b4924166b6728fed359f", - "sha256:23b13edb4df2d4e5d6dc747d83e6b244e267a6615ede90f18ef13dfb2b6feb87", - "sha256:2752c98237057f27594a8393d498edd9db37e06abcfb99176d9cb6fb989dc883", - "sha256:2786c74cbcb0263fd541e4a075aa8c932bdcaa91e5bbb8649c65304799acdd64", - "sha256:281b5ab9514eb241e347a46367a2374cb60cf8f420c4283948aa188f05e7810c", - "sha256:288365a33049dae3065cdb2c2dd4b48df4b64839c565761c4f3f0c360460a561", - "sha256:2a418d596536a470f6f8e94cbb1fde66fe65e03d68c403eee0f2198b129e139a", - "sha256:3c12e6c1331ee8833fcb565c033f7eb4cb5642af37cef81211c222b617b170df", - "sha256:3e4be641fed120790241ae15fde27374a62cadaadcc0bd2b4ce35790bd284fb6", - "sha256:3f1a697262e28682222f18fae70eb0800dfa50c6eb96b0561c6beb83d6cf78ca", - "sha256:3fb3d9e3940ea15b30404200e768e6111c3ee2956c60ceb001cae057961ab058", - "sha256:445a53bce8344e62df4ed9a22fdd1f06cad8e404ead64b2a1f19bd826c8dad1b", - "sha256:4875d1c3ab3d1d9a9d8485dc1f4c2aaa63947824af03301911ea58d1e881e096", - "sha256:4c06f014fd8fa3827e5fd03ec012945e2139901f261fcc401e0622476cad9c5c", - "sha256:4eae86193fd667667f35367d292b912685cb22c3f9f1dd6deaa3fdd713ab5976", - "sha256:56ec8098dcc47817c8aee8037165f0fe30fec8efe543c66e0924781a4bfcbdfd", - "sha256:5a5b76b47b62de16d26439d362b18d71394ca4376eb2c8838352be64b27ba8af", - "sha256:5ade11f4939b885303d28b53d512e96e1a8ea8fbebedd6fef3e2e1afe633cc2a", - "sha256:5f86250ee98f6098479936b7d596418b6e4c919dfa156508e9d6ac5f8bfbe764", - "sha256:61b60c2a07b6d25f7ce8cc0101d55fb0f1af388bec1eddfe0181085c2206e7b0", - "sha256:633bbda2d30bc695900f6a07de4e5d92a4e8e8d0d8a536bb3c2051bee4dc3856", - "sha256:678990bc5a1e4fa36e18d340d439079a21e6b8d249848b7066cad1a6cbd34b82", - "sha256:6cff048a155024a580fee9f9a66b0ad9fc82683f6470c26eb76dd9280e6f459e", - "sha256:6f2e7710f3c468519f9d5b01a291c407f809f8f831e5a204b238e02447046d78", - "sha256:6fad8f03dc976e710db785abf9deb76eb259312fb54d77b568c73f0162cef96e", - "sha256:7078dd0eac3a1dccf2c6f474004dbe8a4e936dbd19d37bbfb6efa70c923ae04e", - "sha256:715b238c1772ed28b98af8830df41c5d68941729e22384fe1433db495b1d5438", - "sha256:72fe11675685412917363481b79c56e68175e62352f84ca4788ac264f9ea6ed0", - "sha256:77697c303b874daf1c76d4e167cd5d6871c26964bc189e4bdb40427067d53a86", - "sha256:79e2494047826a56f2951b2ada9dc139d2c3aff63122e86953cafe64ac0fde75", - "sha256:7cf000319db10a0cb5c7ce91bfd2a8699086b5cc0b5c5b83b92eec22a0448b2f", - "sha256:7d66eeab61956e231f35659e6d5b66dc04a3d51e65f2b8f71862dc6a8ba710d1", - "sha256:7ed4111f305770e35070e49fbb9fbf757a9b6c9a31bb86d352eb4031d4aa976f", - "sha256:7fd212e7022c70b4f8246dee4449dde30ff50c7e8e1d61ac87b7879579badd03", - "sha256:81758da7c76b4e2ddabc4a98a51f3c3aca8585a6d3a8662b5061613303bd5f68", - "sha256:86626d560ceb9d846d128b9c7bd2d0f247dbb62fb49c386762d109583140bf48", - "sha256:8982909857b09220ee31d9a45699fce26f8e5b94a10efa7fe07004d4f4200a33", - "sha256:8eb46ac94d5c131336dc997a568f5579501958b14a507e6aa4840f6d856da980", - "sha256:9af48a2f4cc5e2e34cf69969079865100e418c27caa26c1e3369efcc20c81e17", - "sha256:9dc5a2726fd16c266d35838db086fa4e621bb049e3bbe498ab9d54ad5068f726", - "sha256:a3741f4394ba3d55a64949ee11ffdba19e2a2bdaa1319a96a7ab93bf8bd2b9b2", - "sha256:a97c10043bf74d7667be69383312007d54a507fac8fa101be492cc91e279d94d", - "sha256:a9b8a85d62709a86a9a55d4720502e88968483ee7f365bd852b75935dec04e0d", - "sha256:b24f7286a5c4e350284623cf708662f0881fe7bc1146c1a1fe7e6a9be01a8d6b", - "sha256:b639ea88a46f4629645b398c9e7be0366c92e4910203a6314f78469f5e631dc5", - "sha256:b886b6d14cd089396155e6beb2935268bf995057bf24c3e5fd609af55c584a03", - "sha256:bdaf3b31f8343dcc6c20d068c10eb29325dd70f5dc321ebb5fbeaa280436e70e", - "sha256:be90aa6dab180fed523c0c10a6729ad16c9ba79067402d01a4d8aa7ce48d4084", - "sha256:c4eb304743ab285f8f057344d115259fbe31e42151b9aae7610db83d2a7379b1", - "sha256:ca447967131023e98fcb4867f05cf8584adb424b9108180b2414745a6ff41c31", - "sha256:cc7dbe53276429b2ca511a04a3979ce27aa2088fdd28c119c6913dccdfd0e909", - "sha256:e9ff528498d9e5c543bee388023ca91870678ac50724d675853ba85b4f0a459e", - "sha256:ee5fb667aec4ae723d40ada9854128df427b35b526c600cd352ca0240aad4dd7", - "sha256:f6dd785f7a521189b1233d3c86c0b66fb73d4769a1d253ce5b31081c5946f05f", - "sha256:f988f141a9be7a74d2e98d446b2f5411038bad14cdab80f9d1644b2329a71b48", - "sha256:fb260539dd2b64e93c9f2c59caa70d36d2020fb8e26fa17f62459ad50ebf6c24" - ], - "version": "==14.0" + "sha256:004280a140f220c812e65f36944a9ca92d766b6cc4560be652a0a3883a79ed8a", + "sha256:035233b7531fb92a76beefcbf479504db8c72eb3bff41da55aecce3a0f729e54", + "sha256:149e622dc48c10ccc3d2760e5f36753db9cacf3ad7bc7bbbfd7d9c819e286f23", + "sha256:163e7277e1a0bd9fb3c8842a71661ad19c6aa7bb3d6678dc7f89b17fbcc4aeb7", + "sha256:18503d2c5f3943e93819238bf20df71982d193f73dcecd26c94514f417f6b135", + "sha256:1971e62d2caa443e57588e1d82d15f663b29ff9dfe7446d9964a4b6f12c1e700", + "sha256:204e5107f43095012b00f1451374693267adbb832d29966a01ecc4ce1db26faf", + "sha256:2510c09d8e8df777177ee3d40cd35450dc169a81e747455cc4197e63f7e7bfe5", + "sha256:25c35bf84bf7c7369d247f0b8cfa157f989862c49104c5cf85cb5436a641d93e", + "sha256:2f85cf4f2a1ba8f602298a853cec8526c2ca42a9a4b947ec236eaedb8f2dc80c", + "sha256:308e20f22c2c77f3f39caca508e765f8725020b84aa963474e18c59accbf4c02", + "sha256:325b1ccdbf5e5725fdcb1b0e9ad4d2545056479d0eee392c291c1bf76206435a", + "sha256:327b74e915cf13c5931334c61e1a41040e365d380f812513a255aa804b183418", + "sha256:346bee67a65f189e0e33f520f253d5147ab76ae42493804319b5716e46dddf0f", + "sha256:38377f8b0cdeee97c552d20cf1865695fcd56aba155ad1b4ca8779a5b6ef4ac3", + "sha256:3c78383585f47ccb0fcf186dcb8a43f5438bd7d8f47d69e0b56f71bf431a0a68", + "sha256:4059f790b6ae8768471cddb65d3c4fe4792b0ab48e154c9f0a04cefaabcd5978", + "sha256:459bf774c754c35dbb487360b12c5727adab887f1622b8aed5755880a21c4a20", + "sha256:463e1c6ec853202dd3657f156123d6b4dad0c546ea2e2e38be2b3f7c5b8e7295", + "sha256:4676df3fe46956fbb0437d8800cd5f2b6d41143b6e7e842e60554398432cf29b", + "sha256:485307243237328c022bc908b90e4457d0daa8b5cf4b3723fd3c4a8012fce4c6", + "sha256:48a2ef1381632a2f0cb4efeff34efa97901c9fbc118e01951ad7cfc10601a9bb", + "sha256:4b889dbd1342820cc210ba44307cf75ae5f2f96226c0038094455a96e64fb07a", + "sha256:586a356928692c1fed0eca68b4d1c2cbbd1ca2acf2ac7e7ebd3b9052582deefa", + "sha256:58cf7e75dbf7e566088b07e36ea2e3e2bd5676e22216e4cad108d4df4a7402a0", + "sha256:5993260f483d05a9737073be197371940c01b257cc45ae3f1d5d7adb371b266a", + "sha256:5dd6da9bec02735931fccec99d97c29f47cc61f644264eb995ad6c0c27667238", + "sha256:5f2e75431f8dc4a47f31565a6e1355fb4f2ecaa99d6b89737527ea917066e26c", + "sha256:5f9fee94ebafbc3117c30be1844ed01a3b177bb6e39088bc6b2fa1dc15572084", + "sha256:61fc0dfcda609cda0fc9fe7977694c0c59cf9d749fbb17f4e9483929e3c48a19", + "sha256:624459daabeb310d3815b276c1adef475b3e6804abaf2d9d2c061c319f7f187d", + "sha256:62d516c325e6540e8a57b94abefc3459d7dab8ce52ac75c96cad5549e187e3a7", + "sha256:6548f29b0e401eea2b967b2fdc1c7c7b5ebb3eeb470ed23a54cd45ef078a0db9", + "sha256:6d2aad13a200e5934f5a6767492fb07151e1de1d6079c003ab31e1823733ae79", + "sha256:6d6855bbe70119872c05107e38fbc7f96b1d8cb047d95c2c50869a46c65a8e96", + "sha256:70c5be9f416aa72aab7a2a76c90ae0a4fe2755c1816c153c1a2bcc3333ce4ce6", + "sha256:730f42125ccb14602f455155084f978bd9e8e57e89b569b4d7f0f0c17a448ffe", + "sha256:7a43cfdcddd07f4ca2b1afb459824dd3c6d53a51410636a2c7fc97b9a8cf4842", + "sha256:7bd6abf1e070a6b72bfeb71049d6ad286852e285f146682bf30d0296f5fbadfa", + "sha256:7c1e90228c2f5cdde263253fa5db63e6653f1c00e7ec64108065a0b9713fa1b3", + "sha256:7c65ffa900e7cc958cd088b9a9157a8141c991f8c53d11087e6fb7277a03f81d", + "sha256:80c421e07973a89fbdd93e6f2003c17d20b69010458d3a8e37fb47874bd67d51", + "sha256:82d0ba76371769d6a4e56f7e83bb8e81846d17a6190971e38b5de108bde9b0d7", + "sha256:83f91d8a9bb404b8c2c41a707ac7f7f75b9442a0a876df295de27251a856ad09", + "sha256:87c6e35319b46b99e168eb98472d6c7d8634ee37750d7693656dc766395df096", + "sha256:8d23b88b9388ed85c6faf0e74d8dec4f4d3baf3ecf20a65a47b836d56260d4b9", + "sha256:9156c45750b37337f7b0b00e6248991a047be4aa44554c9886fe6bdd605aab3b", + "sha256:91a0fa841646320ec0d3accdff5b757b06e2e5c86ba32af2e0815c96c7a603c5", + "sha256:95858ca14a9f6fa8413d29e0a585b31b278388aa775b8a81fa24830123874678", + "sha256:95df24ca1e1bd93bbca51d94dd049a984609687cb2fb08a7f2c56ac84e9816ea", + "sha256:9b37c184f8b976f0c0a231a5f3d6efe10807d41ccbe4488df8c74174805eea7d", + "sha256:9b6f347deb3dcfbfde1c20baa21c2ac0751afaa73e64e5b693bb2b848efeaa49", + "sha256:9d75baf00138f80b48f1eac72ad1535aac0b6461265a0bcad391fc5aba875cfc", + "sha256:9ef8aa8bdbac47f4968a5d66462a2a0935d044bf35c0e5a8af152d58516dbeb5", + "sha256:a11e38ad8922c7961447f35c7b17bffa15de4d17c70abd07bfbe12d6faa3e027", + "sha256:a1b54689e38d1279a51d11e3467dd2f3a50f5f2e879012ce8f2d6943f00e83f0", + "sha256:a3b3366087c1bc0a2795111edcadddb8b3b59509d5db5d7ea3fdd69f954a8878", + "sha256:a569eb1b05d72f9bce2ebd28a1ce2054311b66677fcd46cf36204ad23acead8c", + "sha256:a7affedeb43a70351bb811dadf49493c9cfd1ed94c9c70095fd177e9cc1541fa", + "sha256:a9a396a6ad26130cdae92ae10c36af09d9bfe6cafe69670fd3b6da9b07b4044f", + "sha256:a9ab1e71d3d2e54a0aa646ab6d4eebfaa5f416fe78dfe4da2839525dc5d765c6", + "sha256:a9cd1af7e18e5221d2878378fbc287a14cd527fdd5939ed56a18df8a31136bb2", + "sha256:a9dcaf8b0cc72a392760bb8755922c03e17a5a54e08cca58e8b74f6902b433cf", + "sha256:b9d7439d7fab4dce00570bb906875734df13d9faa4b48e261c440a5fec6d9708", + "sha256:bcc03c8b72267e97b49149e4863d57c2d77f13fae12066622dc78fe322490fe6", + "sha256:c11d4d16e133f6df8916cc5b7e3e96ee4c44c936717d684a94f48f82edb7c92f", + "sha256:c1dca61c6db1166c48b95198c0b7d9c990b30c756fc2923cc66f68d17dc558fd", + "sha256:c518e84bb59c2baae725accd355c8dc517b4a3ed8db88b4bc93c78dae2974bf2", + "sha256:c7934fd0e920e70468e676fe7f1b7261c1efa0d6c037c6722278ca0228ad9d0d", + "sha256:c7e72ce6bda6fb9409cc1e8164dd41d7c91466fb599eb047cfda72fe758a34a7", + "sha256:c90d6dec6be2c7d03378a574de87af9b1efea77d0c52a8301dd831ece938452f", + "sha256:ceec59f59d092c5007e815def4ebb80c2de330e9588e101cf8bd94c143ec78a5", + "sha256:cf1781ef73c073e6b0f90af841aaf98501f975d306bbf6221683dd594ccc52b6", + "sha256:d04f13a1d75cb2b8382bdc16ae6fa58c97337253826dfe136195b7f89f661557", + "sha256:d6d300f8ec35c24025ceb9b9019ae9040c1ab2f01cddc2bcc0b518af31c75c14", + "sha256:d8dbb1bf0c0a4ae8b40bdc9be7f644e2f3fb4e8a9aca7145bfa510d4a374eeb7", + "sha256:de58647e3f9c42f13f90ac7e5f58900c80a39019848c5547bc691693098ae1bd", + "sha256:deeb929efe52bed518f6eb2ddc00cc496366a14c726005726ad62c2dd9017a3c", + "sha256:df01aea34b6e9e33572c35cd16bae5a47785e7d5c8cb2b54b2acdb9678315a17", + "sha256:e2620453c075abeb0daa949a292e19f56de518988e079c36478bacf9546ced23", + "sha256:e4450fc83a3df53dec45922b576e91e94f5578d06436871dce3a6be38e40f5db", + "sha256:e54affdeb21026329fb0744ad187cf812f7d3c2aa702a5edb562b325191fcab6", + "sha256:e9875a0143f07d74dc5e1ded1c4581f0d9f7ab86c78994e2ed9e95050073c94d", + "sha256:f1c3cf67185543730888b20682fb186fc8d0fa6f07ccc3ef4390831ab4b388d9", + "sha256:f48c749857f8fb598fb890a75f540e3221d0976ed0bf879cf3c7eef34151acee", + "sha256:f779498eeec470295a2b1a5d97aa1bc9814ecd25e1eb637bd9d1c73a327387f6" + ], + "version": "==13.1" }, "yarl": { "hashes": [ - "sha256:06157fb3c58f2736a5e47c8fcbe1afc8b5de6fb28b14d25574af9e62150fcaac", - "sha256:067a63fcfda82da6b198fa73079b1ca40b7c9b7994995b6ee38acda728b64d47", - "sha256:0b1794853124e2f663f0ea54efb0340b457f08d40a1cef78edfa086576179c91", - "sha256:0bdff5e0995522706c53078f531fb586f56de9c4c81c243865dd5c66c132c3b5", - "sha256:117ed8b3732528a1e41af3aa6d4e08483c2f0f2e3d3d7dca7cf538b3516d93df", - "sha256:14bc88baa44e1f84164a392827b5defb4fa8e56b93fecac3d15315e7c8e5d8b3", - "sha256:1654ec814b18be1af2c857aa9000de7a601400bd4c9ca24629b18486c2e35463", - "sha256:16bca6678a83657dd48df84b51bd56a6c6bd401853aef6d09dc2506a78484c7b", - "sha256:1a3b91c44efa29e6c8ef8a9a2b583347998e2ba52c5d8280dbd5919c02dfc3b5", - "sha256:1a52a1ffdd824fb1835272e125385c32fd8b17fbdefeedcb4d543cc23b332d74", - "sha256:1ce36ded585f45b1e9bb36d0ae94765c6608b43bd2e7f5f88079f7a85c61a4d3", - "sha256:299f11b44d8d3a588234adbe01112126010bd96d9139c3ba7b3badd9829261c3", - "sha256:2b24ec55fad43e476905eceaf14f41f6478780b870eda5d08b4d6de9a60b65b4", - "sha256:2d374d70fdc36f5863b84e54775452f68639bc862918602d028f89310a034ab0", - "sha256:2d9f0606baaec5dd54cb99667fcf85183a7477f3766fbddbe3f385e7fc253299", - "sha256:2e7ba4c9377e48fb7b20dedbd473cbcbc13e72e1826917c185157a137dac9df2", - "sha256:2f0a6423295a0d282d00e8701fe763eeefba8037e984ad5de44aa349002562ac", - "sha256:327828786da2006085a4d1feb2594de6f6d26f8af48b81eb1ae950c788d97f61", - "sha256:380e6c38ef692b8fd5a0f6d1fa8774d81ebc08cfbd624b1bca62a4d4af2f9931", - "sha256:3b74ff4767d3ef47ffe0cd1d89379dc4d828d4873e5528976ced3b44fe5b0a21", - "sha256:3e844be8d536afa129366d9af76ed7cb8dfefec99f5f1c9e4f8ae542279a6dc3", - "sha256:459e81c2fb920b5f5df744262d1498ec2c8081acdcfe18181da44c50f51312f7", - "sha256:46ddf6e0b975cd680eb83318aa1d321cb2bf8d288d50f1754526230fcf59ba96", - "sha256:482c122b72e3c5ec98f11457aeb436ae4aecca75de19b3d1de7cf88bc40db82f", - "sha256:561c87fea99545ef7d692403c110b2f99dced6dff93056d6e04384ad3bc46243", - "sha256:578d00c9b7fccfa1745a44f4eddfdc99d723d157dad26764538fbdda37209857", - "sha256:58c8e9620eb82a189c6c40cb6b59b4e35b2ee68b1f2afa6597732a2b467d7e8f", - "sha256:5b29beab10211a746f9846baa39275e80034e065460d99eb51e45c9a9495bcca", - "sha256:5d1d42556b063d579cae59e37a38c61f4402b47d70c29f0ef15cee1acaa64488", - "sha256:5f236cb5999ccd23a0ab1bd219cfe0ee3e1c1b65aaf6dd3320e972f7ec3a39da", - "sha256:62a91aefff3d11bf60e5956d340eb507a983a7ec802b19072bb989ce120cd948", - "sha256:64cc6e97f14cf8a275d79c5002281f3040c12e2e4220623b5759ea7f9868d6a5", - "sha256:6f4c9156c4d1eb490fe374fb294deeb7bc7eaccda50e23775b2354b6a6739934", - "sha256:7294e38f9aa2e9f05f765b28ffdc5d81378508ce6dadbe93f6d464a8c9594473", - "sha256:7615058aabad54416ddac99ade09a5510cf77039a3b903e94e8922f25ed203d7", - "sha256:7e48cdb8226644e2fbd0bdb0a0f87906a3db07087f4de77a1b1b1ccfd9e93685", - "sha256:7f63d176a81555984e91f2c84c2a574a61cab7111cc907e176f0f01538e9ff6e", - "sha256:7f6595c852ca544aaeeb32d357e62c9c780eac69dcd34e40cae7b55bc4fb1147", - "sha256:7fac95714b09da9278a0b52e492466f773cfe37651cf467a83a1b659be24bf71", - "sha256:81713b70bea5c1386dc2f32a8f0dab4148a2928c7495c808c541ee0aae614d67", - "sha256:846dd2e1243407133d3195d2d7e4ceefcaa5f5bf7278f0a9bda00967e6326b04", - "sha256:84c063af19ef5130084db70ada40ce63a84f6c1ef4d3dbc34e5e8c4febb20822", - "sha256:881764d610e3269964fc4bb3c19bb6fce55422828e152b885609ec176b41cf11", - "sha256:8994b29c462de9a8fce2d591028b986dbbe1b32f3ad600b2d3e1c482c93abad6", - "sha256:8c79e9d7e3d8a32d4824250a9c6401194fb4c2ad9a0cec8f6a96e09a582c2cc0", - "sha256:8ee427208c675f1b6e344a1f89376a9613fc30b52646a04ac0c1f6587c7e46ec", - "sha256:949681f68e0e3c25377462be4b658500e85ca24323d9619fdc41f68d46a1ffda", - "sha256:9e275792097c9f7e80741c36de3b61917aebecc08a67ae62899b074566ff8556", - "sha256:9fb815155aac6bfa8d86184079652c9715c812d506b22cfa369196ef4e99d1b4", - "sha256:a2a64e62c7a0edd07c1c917b0586655f3362d2c2d37d474db1a509efb96fea1c", - "sha256:a7ac5b4984c468ce4f4a553df281450df0a34aefae02e58d77a0847be8d1e11f", - "sha256:aa46dce75078fceaf7cecac5817422febb4355fbdda440db55206e3bd288cfb8", - "sha256:ae3476e934b9d714aa8000d2e4c01eb2590eee10b9d8cd03e7983ad65dfbfcba", - "sha256:b0341e6d9a0c0e3cdc65857ef518bb05b410dbd70d749a0d33ac0f39e81a4258", - "sha256:b40d1bf6e6f74f7c0a567a9e5e778bbd4699d1d3d2c0fe46f4b717eef9e96b95", - "sha256:b5c4804e4039f487e942c13381e6c27b4b4e66066d94ef1fae3f6ba8b953f383", - "sha256:b5d6a6c9602fd4598fa07e0389e19fe199ae96449008d8304bf5d47cb745462e", - "sha256:b5f1ac7359e17efe0b6e5fec21de34145caef22b260e978336f325d5c84e6938", - "sha256:c0167540094838ee9093ef6cc2c69d0074bbf84a432b4995835e8e5a0d984374", - "sha256:c180ac742a083e109c1a18151f4dd8675f32679985a1c750d2ff806796165b55", - "sha256:c73df5b6e8fabe2ddb74876fb82d9dd44cbace0ca12e8861ce9155ad3c886139", - "sha256:c7e177c619342e407415d4f35dec63d2d134d951e24b5166afcdfd1362828e17", - "sha256:cbad927ea8ed814622305d842c93412cb47bd39a496ed0f96bfd42b922b4a217", - "sha256:cc353841428d56b683a123a813e6a686e07026d6b1c5757970a877195f880c2d", - "sha256:cc7c92c1baa629cb03ecb0c3d12564f172218fb1739f54bf5f3881844daadc6d", - "sha256:cc7d768260f4ba4ea01741c1b5fe3d3a6c70eb91c87f4c8761bbcce5181beafe", - "sha256:d0eea830b591dbc68e030c86a9569826145df485b2b4554874b07fea1275a199", - "sha256:d216e5d9b8749563c7f2c6f7a0831057ec844c68b4c11cb10fc62d4fd373c26d", - "sha256:d401f07261dc5aa36c2e4efc308548f6ae943bfff20fcadb0a07517a26b196d8", - "sha256:d6324274b4e0e2fa1b3eccb25997b1c9ed134ff61d296448ab8269f5ac068c4c", - "sha256:d8a8b74d843c2638f3864a17d97a4acda58e40d3e44b6303b8cc3d3c44ae2d29", - "sha256:d9b6b28a57feb51605d6ae5e61a9044a31742db557a3b851a74c13bc61de5172", - "sha256:de599af166970d6a61accde358ec9ded821234cbbc8c6413acfec06056b8e860", - "sha256:e594b22688d5747b06e957f1ef822060cb5cb35b493066e33ceac0cf882188b7", - "sha256:e5b078134f48552c4d9527db2f7da0b5359abd49393cdf9794017baec7506170", - "sha256:eb6dce402734575e1a8cc0bb1509afca508a400a57ce13d306ea2c663bad1138", - "sha256:f1790a4b1e8e8e028c391175433b9c8122c39b46e1663228158e61e6f915bf06", - "sha256:f5efe0661b9fcd6246f27957f6ae1c0eb29bc60552820f01e970b4996e016004", - "sha256:f9cbfbc5faca235fbdf531b93aa0f9f005ec7d267d9d738761a4d42b744ea159", - "sha256:fbea1751729afe607d84acfd01efd95e3b31db148a181a441984ce9b3d3469da", - "sha256:fca4b4307ebe9c3ec77a084da3a9d1999d164693d16492ca2b64594340999988", - "sha256:ff5c6771c7e3511a06555afa317879b7db8d640137ba55d6ab0d0c50425cab75" + "sha256:00bb3a559d7bd006a5302ecd7e409916939106a8cdbe31f4eb5e5b9ffcca57ea", + "sha256:0327081978fe186c3390dd4f73f95f825d0bb9c74967e22c2a1a87735974d8f5", + "sha256:041bafaa82b77fd4ec2826d42a55461ec86d999adf7ed9644eef7e8a9febb366", + "sha256:06ec070a2d71415f90dbe9d70af3158e7da97a128519dba2d1581156ee27fb92", + "sha256:07a4b53abe85813c538b9cdbb02909ebe3734e3af466a587df516e960d500cc8", + "sha256:0a843e692f9d5402b3455653f4607dc521de2385f01c5cad7ba4a87c46e2ea8d", + "sha256:10bfe0bef4cf5ea0383886beda004071faadedf2647048b9f876664284c5b60d", + "sha256:18940191ec9a83bbfe63eea61c3e9d12474bb910d5613bce8fa46e84a80b75b2", + "sha256:19e2a4b2935f95fad0949f420514c5d862f5f18058fbbfd8854f496a97d9fd87", + "sha256:1a6b6e95bc621c11cf9ff21012173337e789f2461ebc3b4e5bf65c74ef69adb8", + "sha256:1f5a1ca6eaabfe62718b87eac06d9a47b30cf92ffa065fee9196d3ecd24a3cf1", + "sha256:21050b6cd569980fe20ceeab4baeb900d3f7247270475e42bafe117416a5496c", + "sha256:2597a589859b94d0a5e2f5d30fee95081867926e57cb751f8b44a7dd92da4e79", + "sha256:294c742a273f44511f14b03a9e06b66094dcdf4bbb75a5e23fead548fd5310ae", + "sha256:2eeb9ba53c055740cd282ae9d34eb7970d65e73a46f15adec4b0c1b0f2e55cc2", + "sha256:30ca64521f1a96b72886dd9e8652f16eab11891b4572dcfcfc1ad6d6ccb27abd", + "sha256:325e2beb2cd8654b276e7686a3cd203628dd3fe32d5c616e632bc35a2901fb16", + "sha256:34816f1d833433a16c4832562a050b0a60eac53dcb71b2032e6ebff82d74b6a7", + "sha256:362da97ad4360e4ef1dd24ccdd3bceb18332da7f40026a42f49b7edd686e31c3", + "sha256:3b30f13fac56598474071a4f1ecd66c78fdaf2f8619042d7ca135f72dbb348cf", + "sha256:44088ec0be82fba118ed29b6b429f80bf295297727adae4c257ac297e01e8bcd", + "sha256:44359c52af9c383e5107f3b6301446fc8269599721fa42fafb2afb5f31a42dcb", + "sha256:4ac83b307cc4b8907345b52994055c6c3c2601ceb6fcb94c5ed6a93c6b4e8257", + "sha256:5093a453176a4fad4f9c3006f507cf300546190bb3e27944275a37cfd6323a65", + "sha256:524b3bb7dff320e305bc979c65eddc0342548c56ea9241502f907853fe53c408", + "sha256:5848500b6a01497560969e8c3a7eb1b2570853c74a0ca6f67ebaf6064106c49b", + "sha256:5882faa2a6e684f65ee44f18c701768749a950cbd5e72db452fc07805f6bdec0", + "sha256:5b8af4165e097ff84d9bbb97bb4f4d7f71b9c1c9565a2d0e27d93e5f92dae220", + "sha256:5c3ac5bdcc1375c8ee52784adf94edbce37c471dd2100a117cfef56fe8dbc2b4", + "sha256:5d6be369488d503c8edc14e2f63d71ab2a607041ad216a8ad444fa18e8dea792", + "sha256:5fadcf532fd9f6cbad71485ef8c2462dd9a91d3efc72ca01eb0970792c92552a", + "sha256:607683991bab8607e5158cd290dd8fdaa613442aeab802fe1c237d3a3eee7358", + "sha256:625f31d6650829fba4030b4e7bdb2d69e41510dddfa29a1da27076c199521757", + "sha256:63d46606b20f80a6476f1044bab78e1a69c2e0747f174583e2f12fc70bad2170", + "sha256:6493da9ba5c551978c679ab04856c2cf8f79c316e8ec8c503460a135705edc3b", + "sha256:6563394492c96cb57f4dff0c69c63d2b28b5469c59c66f35a1e6451583cd0ab4", + "sha256:68d21d0563d82aaf46163eac529adac301b20be3181b8a2811f7bd5615466055", + "sha256:68e837b3edfcd037f9706157e7cb8efda832de6248c7d9e893e2638356dfae5d", + "sha256:6b3d2767bd64c62909ea33525b954ba05c8f9726bfdf2141d175da4e344f19ae", + "sha256:6e2c674cfe4c03ad7a4d536b1f808221f0d11a360486b4b032d2557c0bd633ad", + "sha256:70d074d5a96e0954fe6db81ff356f4361397da1cda3f7c127fc0902f671a087e", + "sha256:71730658be0b5de7c570a9795d7404c577b2313c1db370407092c66f70e04ccb", + "sha256:73143dd279e641543da52c55652ad7b4c7c5f79e797f124f58f04cc060f14271", + "sha256:75d04ba8ed335042328086e643e01165e0c24598216f72da709b375930ae3bdb", + "sha256:7825506fbee4055265528ec3532a8197ff26fc53d4978917a4c8ddbb4c1667d7", + "sha256:7983290ede3aaa2c9620879530849532529b4dcbf5b12a0b6a91163a773eadb9", + "sha256:7abd7d15aedb3961a967cc65f8144dbbca42e3626a21c5f4f29919cf43eeafb9", + "sha256:8249147ee81c1cf4d1dc6f26ba28a1b9d92751529f83c308ad02164bb93abd0d", + "sha256:86648c53b10c53db8b967a75fb41e0c89dbec7398f6525e34af2b6c456bb0ac0", + "sha256:8669a110f655c9eb22f16fb68a7d4942020aeaa09f1def584a80183e3e89953c", + "sha256:8b7dd6983c81523f9de0ae6334c3b7a3cb33283936e0525f80c4f713f54a9bb6", + "sha256:8fc727f0fb388debc771eaa7091c092bd2e8b6b4741b73354b8efadcf96d6031", + "sha256:9162ea117ce8bad8ebc95b7376b4135988acd888d2cf4702f8281e3c11f8b81f", + "sha256:94189746c5ad62e1014a16298130e696fe593d031d442ef135fb7787b7a1f820", + "sha256:94ab1185900f43760d5487c8e49f5f1a66f864e36092f282f1813597479b9dfa", + "sha256:96ce879799fee124d241ea3b84448378f638e290c49493d00b706f3fd57ec22b", + "sha256:9aa054d97033beac9cb9b19b7c0b8784b85b12cd17879087ca6bffba57884e02", + "sha256:9c2d1109c8d92059314cc34dd8f0a31f74b720dc140744923ed7ca228bf9b491", + "sha256:a082dc948045606f62dca0228ab24f13737180b253378d6443f5b2b9ef8beefe", + "sha256:a7d317fb80bc17ed4b34a9aad8b80cef34bea0993654f3e8566daf323def7ef9", + "sha256:b06d8b05d0fafef204d635a4711283ddbf19c7c0facdc61b4b775f6e47e2d4be", + "sha256:b1217102a455e3ac9ac293081093f21f0183e978c7692171ff669fee5296fa28", + "sha256:b6c57972a406ea0f61e3f28f2b3a780fb71fbe1d82d267afe5a2f889a83ee7e7", + "sha256:b997a806846c00d1f41d6a251803732837771b2091bead7566f68820e317bfe7", + "sha256:bb129f77ddaea2d8e6e00417b8d907448de3407af4eddacca0a515574ad71493", + "sha256:bb707859218e8335447b210f41a755e7b1367c33e87add884128bba144694a7f", + "sha256:c166ad987265bb343be58cdf4fbc4478cc1d81f2246d2be9a15f94393b269faa", + "sha256:c884dfa56b050f718ea3cbbfd972e29a6f07f63a7449b10d9a20d64f7eec92e2", + "sha256:cbf36099a9b407e1456dbf55844743a98603fcba32d2a46fb3a698d926facf1b", + "sha256:cd529e637cd23204bd82072f6637cff7af2516ad2c132e8f3342cbc84871f7d1", + "sha256:d3309ee667f2d9c7ac9ecf44620d6b274bfdd8065b8c5019ff6795dd887b8fed", + "sha256:d56980374a10c74255fcea6ebcfb0aeca7166d212ee9fd7e823ddef35fb62ad0", + "sha256:d7fa4b033e2f267e37aabcc36949fa89f9f1716a723395912147f9cf3fb437c7", + "sha256:da48cdff56b01ea4282a6d04b83b07a2088351a4a3ff7aacc1e7e9b6b04b90b9", + "sha256:de6917946dc6bc237d4b354e38aa13a232e0c7948fdbdb160edee3862e9d735f", + "sha256:e27861251d9c094f641d39a8a78dd2371fb9a252ea2f689d1ad353a31d46a0bc", + "sha256:e652aa9f8dfa808bc5b2da4d1f4e286cf1d640570fdfa72ffc0c1d16ba114651", + "sha256:e8aa19c39cb20bfb16f0266df175a6004943122cf20707fbf0cacc21f6468a25", + "sha256:ed9c72d5361cfd5af5ccadffa8f8077f4929640e1f938aa0f4b92c5a24996ac5", + "sha256:f7de0d4b6b4d8a77e422eb54d765255c0ec6883ee03b8fd537101633948619d7", + "sha256:fcfd663dc88465ebe41c7c938bdc91c4b01cda96a0d64bf38fd66c1877323771", + "sha256:fd56de8b645421ff09c993fdb0ee9c5a3b50d290a8f55793b500d99b34d0c1ce" ], "markers": "python_version >= '3.9'", - "version": "==1.17.1" + "version": "==1.15.5" }, "zipp": { "hashes": [ - "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4", - "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931" + "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350", + "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29" ], - "markers": "python_version >= '3.9'", - "version": "==3.21.0" + "markers": "python_version >= '3.8'", + "version": "==3.20.2" } }, "develop": { @@ -2123,72 +2138,72 @@ }, "coverage": { "hashes": [ - "sha256:00a1d69c112ff5149cabe60d2e2ee948752c975d95f1e1096742e6077affd376", - "sha256:023bf8ee3ec6d35af9c1c6ccc1d18fa69afa1cb29eaac57cb064dbb262a517f9", - "sha256:0294ca37f1ba500667b1aef631e48d875ced93ad5e06fa665a3295bdd1d95111", - "sha256:06babbb8f4e74b063dbaeb74ad68dfce9186c595a15f11f5d5683f748fa1d172", - "sha256:0809082ee480bb8f7416507538243c8863ac74fd8a5d2485c46f0f7499f2b491", - "sha256:0b3fb02fe73bed561fa12d279a417b432e5b50fe03e8d663d61b3d5990f29546", - "sha256:0b58c672d14f16ed92a48db984612f5ce3836ae7d72cdd161001cc54512571f2", - "sha256:0bcd1069e710600e8e4cf27f65c90c7843fa8edfb4520fb0ccb88894cad08b11", - "sha256:1032e178b76a4e2b5b32e19d0fd0abbce4b58e77a1ca695820d10e491fa32b08", - "sha256:11a223a14e91a4693d2d0755c7a043db43d96a7450b4f356d506c2562c48642c", - "sha256:12394842a3a8affa3ba62b0d4ab7e9e210c5e366fbac3e8b2a68636fb19892c2", - "sha256:182e6cd5c040cec0a1c8d415a87b67ed01193ed9ad458ee427741c7d8513d963", - "sha256:1d5b8007f81b88696d06f7df0cb9af0d3b835fe0c8dbf489bad70b45f0e45613", - "sha256:1f76846299ba5c54d12c91d776d9605ae33f8ae2b9d1d3c3703cf2db1a67f2c0", - "sha256:27fb4a050aaf18772db513091c9c13f6cb94ed40eacdef8dad8411d92d9992db", - "sha256:29155cd511ee058e260db648b6182c419422a0d2e9a4fa44501898cf918866cf", - "sha256:29fc0f17b1d3fea332f8001d4558f8214af7f1d87a345f3a133c901d60347c73", - "sha256:2b6b4c83d8e8ea79f27ab80778c19bc037759aea298da4b56621f4474ffeb117", - "sha256:2fdef0d83a2d08d69b1f2210a93c416d54e14d9eb398f6ab2f0a209433db19e1", - "sha256:3c65d37f3a9ebb703e710befdc489a38683a5b152242664b973a7b7b22348a4e", - "sha256:4f704f0998911abf728a7783799444fcbbe8261c4a6c166f667937ae6a8aa522", - "sha256:51b44306032045b383a7a8a2c13878de375117946d68dcb54308111f39775a25", - "sha256:53d202fd109416ce011578f321460795abfe10bb901b883cafd9b3ef851bacfc", - "sha256:58809e238a8a12a625c70450b48e8767cff9eb67c62e6154a642b21ddf79baea", - "sha256:5915fcdec0e54ee229926868e9b08586376cae1f5faa9bbaf8faf3561b393d52", - "sha256:5beb1ee382ad32afe424097de57134175fea3faf847b9af002cc7895be4e2a5a", - "sha256:5f8ae553cba74085db385d489c7a792ad66f7f9ba2ee85bfa508aeb84cf0ba07", - "sha256:5fbd612f8a091954a0c8dd4c0b571b973487277d26476f8480bfa4b2a65b5d06", - "sha256:6bd818b7ea14bc6e1f06e241e8234508b21edf1b242d49831831a9450e2f35fa", - "sha256:6f01ba56b1c0e9d149f9ac85a2f999724895229eb36bd997b61e62999e9b0901", - "sha256:73d2b73584446e66ee633eaad1a56aad577c077f46c35ca3283cd687b7715b0b", - "sha256:7bb92c539a624cf86296dd0c68cd5cc286c9eef2d0c3b8b192b604ce9de20a17", - "sha256:8165b796df0bd42e10527a3f493c592ba494f16ef3c8b531288e3d0d72c1f6f0", - "sha256:862264b12ebb65ad8d863d51f17758b1684560b66ab02770d4f0baf2ff75da21", - "sha256:8902dd6a30173d4ef09954bfcb24b5d7b5190cf14a43170e386979651e09ba19", - "sha256:8cf717ee42012be8c0cb205dbbf18ffa9003c4cbf4ad078db47b95e10748eec5", - "sha256:8ed9281d1b52628e81393f5eaee24a45cbd64965f41857559c2b7ff19385df51", - "sha256:99b41d18e6b2a48ba949418db48159d7a2e81c5cc290fc934b7d2380515bd0e3", - "sha256:9cb7fa111d21a6b55cbf633039f7bc2749e74932e3aa7cb7333f675a58a58bf3", - "sha256:a181e99301a0ae128493a24cfe5cfb5b488c4e0bf2f8702091473d033494d04f", - "sha256:a413a096c4cbac202433c850ee43fa326d2e871b24554da8327b01632673a076", - "sha256:a6b1e54712ba3474f34b7ef7a41e65bd9037ad47916ccb1cc78769bae324c01a", - "sha256:ade3ca1e5f0ff46b678b66201f7ff477e8fa11fb537f3b55c3f0568fbfe6e718", - "sha256:b0ac3d42cb51c4b12df9c5f0dd2f13a4f24f01943627120ec4d293c9181219ba", - "sha256:b369ead6527d025a0fe7bd3864e46dbee3aa8f652d48df6174f8d0bac9e26e0e", - "sha256:b57b768feb866f44eeed9f46975f3d6406380275c5ddfe22f531a2bf187eda27", - "sha256:b8d3a03d9bfcaf5b0141d07a88456bb6a4c3ce55c080712fec8418ef3610230e", - "sha256:bc66f0bf1d7730a17430a50163bb264ba9ded56739112368ba985ddaa9c3bd09", - "sha256:bf20494da9653f6410213424f5f8ad0ed885e01f7e8e59811f572bdb20b8972e", - "sha256:c48167910a8f644671de9f2083a23630fbf7a1cb70ce939440cd3328e0919f70", - "sha256:c481b47f6b5845064c65a7bc78bc0860e635a9b055af0df46fdf1c58cebf8e8f", - "sha256:c7c8b95bf47db6d19096a5e052ffca0a05f335bc63cef281a6e8fe864d450a72", - "sha256:c9b8e184898ed014884ca84c70562b4a82cbc63b044d366fedc68bc2b2f3394a", - "sha256:cc8ff50b50ce532de2fa7a7daae9dd12f0a699bfcd47f20945364e5c31799fef", - "sha256:d541423cdd416b78626b55f123412fcf979d22a2c39fce251b350de38c15c15b", - "sha256:dab4d16dfef34b185032580e2f2f89253d302facba093d5fa9dbe04f569c4f4b", - "sha256:dacbc52de979f2823a819571f2e3a350a7e36b8cb7484cdb1e289bceaf35305f", - "sha256:df57bdbeffe694e7842092c5e2e0bc80fff7f43379d465f932ef36f027179806", - "sha256:ed8fe9189d2beb6edc14d3ad19800626e1d9f2d975e436f84e19efb7fa19469b", - "sha256:f3ddf056d3ebcf6ce47bdaf56142af51bb7fad09e4af310241e9db7a3a8022e1", - "sha256:f8fe4984b431f8621ca53d9380901f62bfb54ff759a1348cd140490ada7b693c", - "sha256:fe439416eb6380de434886b00c859304338f8b19f6f54811984f3420a2e03858" + "sha256:04f2189716e85ec9192df307f7c255f90e78b6e9863a03223c3b998d24a3c6c6", + "sha256:0c6c0f4d53ef603397fc894a895b960ecd7d44c727df42a8d500031716d4e8d2", + "sha256:0ca37993206402c6c35dc717f90d4c8f53568a8b80f0bf1a1b2b334f4d488fba", + "sha256:12f9515d875859faedb4144fd38694a761cd2a61ef9603bf887b13956d0bbfbb", + "sha256:1990b1f4e2c402beb317840030bb9f1b6a363f86e14e21b4212e618acdfce7f6", + "sha256:2341a78ae3a5ed454d524206a3fcb3cec408c2a0c7c2752cd78b606a2ff15af4", + "sha256:23bb63ae3f4c645d2d82fa22697364b0046fbafb6261b258a58587441c5f7bd0", + "sha256:27bd5f18d8f2879e45724b0ce74f61811639a846ff0e5c0395b7818fae87aec6", + "sha256:2dc7d6b380ca76f5e817ac9eef0c3686e7834c8346bef30b041a4ad286449990", + "sha256:331b200ad03dbaa44151d74daeb7da2cf382db424ab923574f6ecca7d3b30de3", + "sha256:365defc257c687ce3e7d275f39738dcd230777424117a6c76043459db131dd43", + "sha256:37be7b5ea3ff5b7c4a9db16074dc94523b5f10dd1f3b362a827af66a55198175", + "sha256:3c2e6fa98032fec8282f6b27e3f3986c6e05702828380618776ad794e938f53a", + "sha256:40e8b1983080439d4802d80b951f4a93d991ef3261f69e81095a66f86cf3c3c6", + "sha256:43517e1f6b19f610a93d8227e47790722c8bf7422e46b365e0469fc3d3563d97", + "sha256:43b32a06c47539fe275106b376658638b418c7cfdfff0e0259fbf877e845f14b", + "sha256:43d6a66e33b1455b98fc7312b124296dad97a2e191c80320587234a77b1b736e", + "sha256:4c59d6a4a4633fad297f943c03d0d2569867bd5372eb5684befdff8df8522e39", + "sha256:52ac29cc72ee7e25ace7807249638f94c9b6a862c56b1df015d2b2e388e51dbd", + "sha256:54356a76b67cf8a3085818026bb556545ebb8353951923b88292556dfa9f812d", + "sha256:583049c63106c0555e3ae3931edab5669668bbef84c15861421b94e121878d3f", + "sha256:6d99198203f0b9cb0b5d1c0393859555bc26b548223a769baf7e321a627ed4fc", + "sha256:6da42bbcec130b188169107ecb6ee7bd7b4c849d24c9370a0c884cf728d8e976", + "sha256:6e484e479860e00da1f005cd19d1c5d4a813324e5951319ac3f3eefb497cc549", + "sha256:70a6756ce66cd6fe8486c775b30889f0dc4cb20c157aa8c35b45fd7868255c5c", + "sha256:70d24936ca6c15a3bbc91ee9c7fc661132c6f4c9d42a23b31b6686c05073bde5", + "sha256:71967c35828c9ff94e8c7d405469a1fb68257f686bca7c1ed85ed34e7c2529c4", + "sha256:79644f68a6ff23b251cae1c82b01a0b51bc40c8468ca9585c6c4b1aeee570e0b", + "sha256:87cd2e29067ea397a47e352efb13f976eb1b03e18c999270bb50589323294c6e", + "sha256:8d4c6ea0f498c7c79111033a290d060c517853a7bcb2f46516f591dab628ddd3", + "sha256:9134032f5aa445ae591c2ba6991d10136a1f533b1d2fa8f8c21126468c5025c6", + "sha256:921fbe13492caf6a69528f09d5d7c7d518c8d0e7b9f6701b7719715f29a71e6e", + "sha256:99670790f21a96665a35849990b1df447993880bb6463a0a1d757897f30da929", + "sha256:9975442f2e7a5cfcf87299c26b5a45266ab0696348420049b9b94b2ad3d40234", + "sha256:99ded130555c021d99729fabd4ddb91a6f4cc0707df4b1daf912c7850c373b13", + "sha256:a3328c3e64ea4ab12b85999eb0779e6139295bbf5485f69d42cf794309e3d007", + "sha256:a4fb91d5f72b7e06a14ff4ae5be625a81cd7e5f869d7a54578fc271d08d58ae3", + "sha256:aa23ce39661a3e90eea5f99ec59b763b7d655c2cada10729ed920a38bfc2b167", + "sha256:aac7501ae73d4a02f4b7ac8fcb9dc55342ca98ffb9ed9f2dfb8a25d53eda0e4d", + "sha256:ab84a8b698ad5a6c365b08061920138e7a7dd9a04b6feb09ba1bfae68346ce6d", + "sha256:b4adeb878a374126f1e5cf03b87f66279f479e01af0e9a654cf6d1509af46c40", + "sha256:b9853509b4bf57ba7b1f99b9d866c422c9c5248799ab20e652bbb8a184a38181", + "sha256:bb7d5fe92bd0dc235f63ebe9f8c6e0884f7360f88f3411bfed1350c872ef2054", + "sha256:bca4c8abc50d38f9773c1ec80d43f3768df2e8576807d1656016b9d3eeaa96fd", + "sha256:c222958f59b0ae091f4535851cbb24eb57fc0baea07ba675af718fb5302dddb2", + "sha256:c30e42ea11badb147f0d2e387115b15e2bd8205a5ad70d6ad79cf37f6ac08c91", + "sha256:c3a79f56dee9136084cf84a6c7c4341427ef36e05ae6415bf7d787c96ff5eaa3", + "sha256:c51ef82302386d686feea1c44dbeef744585da16fcf97deea2a8d6c1556f519b", + "sha256:c77326300b839c44c3e5a8fe26c15b7e87b2f32dfd2fc9fee1d13604347c9b38", + "sha256:d33a785ea8354c480515e781554d3be582a86297e41ccbea627a5c632647f2cd", + "sha256:d546cfa78844b8b9c1c0533de1851569a13f87449897bbc95d698d1d3cb2a30f", + "sha256:da29ceabe3025a1e5a5aeeb331c5b1af686daab4ff0fb4f83df18b1180ea83e2", + "sha256:df8c05a0f574d480947cba11b947dc41b1265d721c3777881da2fb8d3a1ddfba", + "sha256:e266af4da2c1a4cbc6135a570c64577fd3e6eb204607eaff99d8e9b710003c6f", + "sha256:e279f3db904e3b55f520f11f983cc8dc8a4ce9b65f11692d4718ed021ec58b83", + "sha256:ea52bd218d4ba260399a8ae4bb6b577d82adfc4518b93566ce1fddd4a49d1dce", + "sha256:ebec65f5068e7df2d49466aab9128510c4867e532e07cb6960075b27658dca38", + "sha256:ec1e3b40b82236d100d259854840555469fad4db64f669ab817279eb95cd535c", + "sha256:ee77c7bef0724165e795b6b7bf9c4c22a9b8468a6bdb9c6b4281293c6b22a90f", + "sha256:f263b18692f8ed52c8de7f40a0751e79015983dbd77b16906e5b310a39d3ca21", + "sha256:f7b26757b22faf88fcf232f5f0e62f6e0fd9e22a8a5d0d5016888cdfe1f6c1c4", + "sha256:f7ddb920106bbbbcaf2a274d56f46956bf56ecbde210d88061824a95bdd94e92" ], "index": "pypi", "markers": "python_version >= '3.9'", - "version": "==7.6.4" + "version": "==7.6.3" }, "django": { "hashes": [ @@ -2420,11 +2435,11 @@ }, "packaging": { "hashes": [ - "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", - "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f" + "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", + "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124" ], "markers": "python_version >= '3.8'", - "version": "==24.2" + "version": "==24.1" }, "pathspec": { "hashes": [ @@ -2486,11 +2501,11 @@ }, "setuptools": { "hashes": [ - "sha256:f2504966861356aa38616760c0f66568e535562374995367b4e69c7143cf6bcd", - "sha256:fba5dd4d766e97be1b1681d98712680ae8f2f26d7881245f2ce9e40714f1a686" + "sha256:753bb6ebf1f465a1912e19ed1d41f403a79173a9acf66a42e7e6aec45c3c16ec", + "sha256:a7fcb66f68b4d9e8e66b42f9876150a3371558f98fa32222ffaa5bced76406f8" ], "markers": "python_version >= '3.8'", - "version": "==75.3.0" + "version": "==75.2.0" }, "sqlparse": { "hashes": [ diff --git a/backend/README.md b/backend/README.md index 78598a8cd..2a1e48bea 100644 --- a/backend/README.md +++ b/backend/README.md @@ -4,61 +4,50 @@ Welcome to the Penn Courses Backend (PCX)! ### Prerequisites -- [`docker` and `docker-compose`](https://docs.docker.com/get-docker/) -- `psql` (usually packaged as `postgresql-client`) +We develop the PCX backend primarily within Dev Containers. Follow the steps outlined in `penn-courses/README.md` to ensure your container gets initialized properly, then proceed with the following steps. + +If you don't want to develop in Dev Container, see the [Running the Backend Natively](#running-the-backend-natively) section. You might want to do this if, for example, you really like your local set up. ### Running the Backend with Docker-Compose -1. Build the docker image (the `[sudo]` part means try running without sudo first) -```sh -cd backend -[sudo] docker-compose down -[sudo] docker-compose build --no-cache development -``` -> :warning: Depending on your system configuration, you may have to start `docker` manually. If this is the case (ie, if you cannot get `docker-compose run` to work due to a docker connection error) try this: -> -> - linux `[sudo] systemctl start docker` -> - WSL `[sudo] service docker start` -2. Run shell inside the container -```sh -[sudo] docker-compose run --service-ports development /bin/bash # launch shell inside container -pipenv shell # activate shell with python packages -python manage.py makemigrations -python manage.py migrate # database migrations -python manage.py test # run tests -python manage.py test tests.review.test_api.OneReviewTestCase.test_course # re-run a specific test -runserver # optionally, run the server from within the shell -exit # leave pipenv shell -exit # leave docker shell -``` -3. Run the server on `127.0.0.1:8000` (use `CTRL+C` to stop running this) -``` -[sudo] docker-compose --profile dev up -``` - -**If you're a frontend developer** you'll want to use #2 from now on (only re-running #2 or #1 when you see a problem) - -**If you're a backend developer** you'll often want to open the shell as we did in #2 (you'll only need to re-run #1 if you see a problem). - -> *If you want to run the backend natively (ie, outside of docker-compose), see the [Running the Backend Natively](#running-the-backend-natively) section. You might want to do this if, for example, you really like your local shell set up* - -### Attaching to Docker from IDE - -Many IDEs allow attachment to running docker containers, which allows for nice features like intellisense. -1. [VSCode](https://code.visualstudio.com/docs/devcontainers/attach-container) -> 1. `CTRL-SHIFT-P` and type "Attach to Running Container" -> 2. Select the `backend_development_1` container (or a similarly named one). This should open a new VSCode window attached to the container -> 3. Open the `/backend` folder within the container -2. [PyCharm](https://www.jetbrains.com/help/pycharm/using-docker-as-a-remote-interpreter.html#config-docker) +1. `cd backend` + +2. Running Docker + 1. Open a new terminal window (also in the `backend` directory) and run `docker compose up` + + > :warning: Dev Containers should be automatically running the docker daemon. However, if this is not the case (e.g. you're facing Docker connection errors or seeing `unable to start container process` errors) follow the steps in [Running the Backend Natively](#running-the-backend-natively). + +3. Set up Django Development Environment + 1. `pipenv install --dev` – Downloads necessary packages. + 2. `pipenv shell` – Enters virtual environment for development. + 3. `python manage.py makemigrations` – Generates SQL files that propagate Django Model changes to database. + 4. `python manage.py migrate` – Applies migration files to database (requires a running database). + 5. `python manage.py test` – Run test suite. + 6. `python manage.py test tests.review.test_api.OneReviewTestCase.test_course` – Run a specific test, or a set of tests by specifying a prefix path (e.g. `tests.review.test_api`). + +4. Load test data into DB, following steps in [Loading Courses Data](#loading-courses-data). + + > NOTE: If for some reason this is not possible, ensure that you have created a local user named "Penn-Courses" with the password "postgres" in your PostgreSQL. To add the user, navigate to your pgAdmin, and follow the path of Object -> Create -> Login/Group Role and create the appropriate user. + +5. Run the backend server. + - Run the backend in development mode with the command `python manage.py runserver`. This will start the server at port `8000`. + - Once the server is running, you can access the admin console at `localhost:8000/admin`, browse auto-generated API documentation from the code on your branch at `localhost:8000/api/documentation`, or use any of the other routes supported by this backend (comprehensively described by the API documentation), usually of the form `localhost:8000/api/...`. + + > NOTE: if you don't need documentation specific to your branch, it is usually more convenient to browse the API docs at [penncoursereview.com/api/documentation](https://penncoursereview.com/api/documentation). + + - With the backend server running, you can also run the frontend for any of our PCX products by following the instructions in the `frontend` README. + + > :warning: If you ever encounter a `pg_hba.conf` entry error, open the `~/var/lib/postgresql/data/pg_hba.conf` file in your docker container and append the line `host all all 0.0.0.0/0 md5` into the file. + +**If you're a frontend developer** you should only need to run Step 5 from now on (only re-running all steps if you see a problem). +**If you're a backend developer** you'll often want to rerun #3 and #5, in the case that you are making DB changes, installing new packages, etc. ## Environment Variables -If you are in Penn Labs, reach out to a Penn Courses team lead for a .env file to -put in your `backend` directory. This will contain some sensitive credentials (which is why the file contents are not -pasted in this public README). If you are not in Penn Labs, see the "Loading Course Data on Demand" section below for instructions on how to get your own credentials. +If you are in Penn Labs, reach out to a Penn Courses team lead for a .env file to put in your `backend` directory. This will contain some sensitive credentials (which is why the file contents are not pasted in this public README). If you are not in Penn Labs, see the "Loading Course Data on Demand" section below for instructions on how to get your own credentials. -NOTE: when using `pipenv`, environment variables are only refreshed when you exit your shell and rerun `pipenv shell` (this is a common source of confusing behavior, so it's good to know about). +> NOTE: when using `pipenv`, environment variables are only refreshed when you exit your shell and rerun `pipenv shell` (this is a common source of confusing behavior, so it's good to know about). ## Linting @@ -67,17 +56,18 @@ We use `black`, `flake8`, and 'isort' to lint our code. Once you are in the `bac 2. `pipenv run isort` 3. `pipenv run flake8` +Please try to run these commands before committing your code – CI checks will fail when your code isn't properly linted. + ## Loading Courses Data ### Via Database Dump (Penn Labs members) -- To get going quickly with a local database loaded with lots of test data, - you can download this [pcx_test.sql](https://penn-labs.slack.com/files/U02FND52FLJ/F06GLQP0UF2/pcx_test_1_2024.sql) SQL dump file. You will only be able to access this if you are a member of labs; if you still need access to data, read on. -- First you'll need to install `psql` (see [Prerequisites](#prerequisites)) -- Clear the existing contents of your local database with `psql template1 -c 'drop database postgres;' -h localhost -U penn-courses` (the password is `postgres`) +- To get going quickly with a local database loaded with lots of test data, you can download this [pcx_test.sql](https://penn-labs.slack.com/files/U04NPQQ2WRF/F07SHJSUKHT/pcx_test_10_2024.sql.zip) SQL dump file. You will only be able to access this if you are a member of Labs; if you still need access to data, read on. +- Clear the existing contents of your local database with `psql template1 -c 'drop database postgres;' -h localhost -U penn-courses` (the password is `postgres`). - Create a new database with `psql template1 -c 'create database postgres with owner "penn-courses";' -h localhost -U penn-courses` (same password). - > :warning: NOTE: If this is giving you permission denied, try running `psql template1` and enter the following query `CREATE DATABASE postgres WITH OWNER "penn-courses"`. + > :warning: If this is giving you permission denied, try running `psql template1` and enter the following query `CREATE DATABASE postgres WITH OWNER "penn-courses"`. + - Finally, run `psql -h localhost -d postgres -U penn-courses -f pcx_test.sql` (replacing `pcx_test.sql` with the full path to that file on your computer) to load the contents of the test database (this might take a while). - For accessing the Django admin site, the admin username is `admin` and the password is `admin` if you use this test db. @@ -143,20 +133,16 @@ prompts, add the `--force` flag. ## Running the Backend Natively -If you don't want to use docker alone, you can also set up and run the dev environment more natively. +If you don't want to develop within a Docker container, you can also choose to run the dev environment natively. ### Prerequisites - Python 3.11 ([`pyenv`](https://github.com/pyenv/pyenv) is recommended) - [`pipenv`](https://pipenv.pypa.io/en/latest/) - [`docker` and `docker-compose`](https://docs.docker.com/get-docker/) - -`psql` is required to load data into the db, but it should be installed when you install `postgres`/`psycopg2`. - -1. `cd backend` -2. Compiling postgres (`psycopg2`) +- Postgres Server (`psycopg2`) - **Mac** - > :warning: NOTE: If your computer runs on Apple silicon and you use Rosetta to run Python as an x86 program, use `arch -x86_64 brew ` for all `brew` commands. + > NOTE: If your computer runs on Apple silicon and you use Rosetta to run Python as an x86 program, use `arch -x86_64 brew ` for all `brew` commands. 1. `brew install postgresql` 2. `brew install openssl` @@ -167,36 +153,7 @@ If you don't want to use docker alone, you can also set up and run the dev envir - `export CPPFLAGS="-I/usr/local/opt/openssl@3/include"` - **Windows (WSL) or Linux:** - - `apt-get install gcc python3-dev libpq-dev` - -3. Running Docker - 1. Open a new terminal window (also in the `backend` directory) and run `docker-compose up` - > :warning: Depending on your system configuration, you may have to start `docker` manually. If this is the case (ie, if you cannot get `docker-compose up` to work due to a docker connection error) try this: - > - > - (linux) `[sudo] systemctl start docker` - > - (WSL) `[sudo] service docker start` - -4. Setting up your Penn Courses development environment - - 1. `pipenv install --dev` - 2. `pipenv shell` - 3. `python manage.py migrate` - -5. Loading test data (if you are a member of Penn Labs). If you are not a member of Penn Labs, you can skip this section and load in course data from the registrar, as explained below. - - -6. Running the backend - - - Run the backend in development mode with the command `python manage.py runserver`. This will start the server at port `8000`. - - Once the server is running, you can access the admin console at `localhost:8000/admin`, browse auto-generated API documentation from the code on your branch at `localhost:8000/api/documentation`, or use any of the other routes supported by this backend (comprehensively described by the API documentation), usually of the form `localhost:8000/api/...` - - > :warning: NOTE: if you don't need documentation specific to your branch, it is usually more convenient to browse the API docs at [penncoursereview.com/api/documentation](https://penncoursereview.com/api/documentation) - - With the backend server running, you can also run the frontend for any of our PCX products by following the instructions in the `frontend` README. - - > :warning: NOTE: If you have not loaded the test data from the previous step (Step 4), ensure that you have created a local user named "Penn-Courses" with the password "postgres" in your PostgreSQL. To add the user, navigate to your pgAdmin, and follow the path of Object -> Create -> Login/Group Role and create the appropriate user. - - > :warning: NOTE: If you ever encounter a `pg_hba.conf` entry error, open the `~/var/lib/postgresql/data/pg_hba.conf` file in your docker container and append the line `host all all 0.0.0.0/0 md5` into the file. + - `apt-get install gcc python3-dev libpq-dev postgresql-client` -7. Running tests - - Run `python manage.py test` to run our test suite. - - To run a specific test, you can use the format `python manage.py test tests.review.test_api.OneReviewTestCase.test_course` (also note that in this example, you can use any prefix of that path to run a larger set of tests). +### Running the Backend +Follow steps from #3 onwards in the [Running the Backend with Docker-Compose](#running-the-backend-with-docker-compose) section. \ No newline at end of file diff --git a/backend/courses/management/commands/registrarimport.py b/backend/courses/management/commands/registrarimport.py index d521d4322..4a698bfe2 100644 --- a/backend/courses/management/commands/registrarimport.py +++ b/backend/courses/management/commands/registrarimport.py @@ -10,6 +10,7 @@ from courses.models import Department, Section from courses.util import get_current_semester, upsert_course_from_opendata from review.management.commands.clearcache import clear_cache +from review.management.commands.precompute_pcr_views import precompute_pcr_views def registrar_import(semester=None, query=""): @@ -45,6 +46,8 @@ def registrar_import(semester=None, query=""): # (cron job only does current semester, which is either fall or spring) registrar_import(semester=semester[:-1] + "B", query=query) + precompute_pcr_views(verbose=True, is_new_data=False) + class Command(BaseCommand): help = "Load in courses, sections and associated models from the Penn registrar and requirements data sources." # noqa: E501 diff --git a/backend/courses/urls.py b/backend/courses/urls.py index 0f4cd4f16..a2f85ab12 100644 --- a/backend/courses/urls.py +++ b/backend/courses/urls.py @@ -1,10 +1,11 @@ from django.urls import path from courses import views -from courses.views import CourseListSearch +from courses.views import CourseListSearch, Health urlpatterns = [ + path("health/", Health.as_view(), name="health"), path("/courses/", views.CourseList.as_view(), name="courses-list"), path( "/search/courses/", diff --git a/backend/courses/views.py b/backend/courses/views.py index 20cc5053a..132eb0929 100644 --- a/backend/courses/views.py +++ b/backend/courses/views.py @@ -9,6 +9,7 @@ from rest_framework.exceptions import ValidationError from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from rest_framework.views import APIView from courses.filters import CourseSearchFilterBackend from courses.models import ( @@ -49,6 +50,11 @@ ) +class Health(APIView): + def get(self, request): + return Response({"message": "OK"}, status=status.HTTP_200_OK) + + class BaseCourseMixin(AutoPrefetchViewSetMixin, generics.GenericAPIView): @staticmethod def get_semester_field(): diff --git a/backend/docker-compose.yaml b/backend/docker-compose.yaml index fbabce8ba..2f350b5f7 100644 --- a/backend/docker-compose.yaml +++ b/backend/docker-compose.yaml @@ -1,8 +1,6 @@ -version: "3" - services: db: - image: postgres + image: postgres:15.8 environment: - POSTGRES_DB=postgres - POSTGRES_USER=penn-courses diff --git a/backend/review/import_utils/import_to_db.py b/backend/review/import_utils/import_to_db.py index 33b345d0d..bb9a3e56d 100644 --- a/backend/review/import_utils/import_to_db.py +++ b/backend/review/import_utils/import_to_db.py @@ -80,7 +80,12 @@ def import_review(section, instructor, enrollment, responses, form_type, bits, s ) if not created: stat("duplicate_review") - review_bits = [ReviewBit(review=review, field=k, average=v) for k, v in bits.items()] + review_bits = [] + for key, value in bits.items(): + if value is None or value == "null": + stat(f"null value for {key}") + continue + review_bits.append(ReviewBit(review=review, field=key, average=value)) # This saves us a bunch of database calls per row, since reviews have > 10 bits. ReviewBit.objects.bulk_create(review_bits, ignore_conflicts=True) @@ -195,7 +200,7 @@ def import_ratings_row(row, stat): } for key, val in details.items(): - if val is None: + if val is None or val == "null": stat(f"null value for {key}") return diff --git a/backend/review/import_utils/parse_sql.py b/backend/review/import_utils/parse_sql.py index af9371e20..f6ace78a0 100644 --- a/backend/review/import_utils/parse_sql.py +++ b/backend/review/import_utils/parse_sql.py @@ -98,14 +98,42 @@ def number(self, n): (n,) = n return float(n) + def _convert_dd_mon_rr_format(self, date_str: str) -> datetime: + """ + Implemented RR year format logic. + """ + datetime_elems = date_str.split("-") + arg_year_last_digits = int(datetime_elems[-1]) + + current_year = datetime.now().year + current_year_first_digits = current_year // 100 + current_year_last_digits = current_year % 100 + + arg_year_first_digits = current_year_first_digits + if arg_year_last_digits <= 49: + if current_year_last_digits > 49: + arg_year_first_digits += 1 + else: + if current_year_first_digits <= 49: + arg_year_first_digits -= 1 + + datetime_elems[-1] = str(arg_year_first_digits * 100 + arg_year_last_digits) + return datetime.strptime("-".join(datetime_elems), "%d-%b-%Y") + def date(self, items): """ The dump includes the format (parsed at items[1] in this function), - but it's not the same parse tokens that Python uses. From observation, - all the dates are in the same format. If that changes, and dates start - being off, here's a good place to look. + but it's not the same parse tokens that Python uses. We've previously + seen dates in two different formats, but if you encounter a format + not of type '%m/%d/%Y %H:%M:%S' or 'DD-MON-RR', then this function + will need to be modified. """ - return datetime.strptime(items[0], "%m/%d/%Y %H:%M:%S") + if items[1] == "MM/DD/YYYY HH24:MI:SS": + return datetime.strptime(items[0], "%m/%d/%Y %H:%M:%S") + elif items[1] == "DD-MON-RR": + return self._convert_dd_mon_rr_format(items[0]) + + raise ValueError("Received invalid date format.") class TypeTransformer(SQLDumpTransformer): diff --git a/backend/review/management/commands/clearcache.py b/backend/review/management/commands/clearcache.py index 63a44ca85..bada37c1b 100644 --- a/backend/review/management/commands/clearcache.py +++ b/backend/review/management/commands/clearcache.py @@ -5,6 +5,8 @@ from django.core.cache import cache from django.core.management import BaseCommand +from PennCourses.settings.base import CACHE_PREFIX + def clear_cache(): # If we are not using redis as the cache backend, then we can delete everything from the cache. @@ -22,6 +24,9 @@ def clear_cache(): for key in r.scan_iter("*views.decorators.cache*"): r.delete(key) del_count += 1 + for key in r.scan_iter(f"*{CACHE_PREFIX}*"): + r.delete(key) + del_count += 1 return del_count diff --git a/backend/review/management/commands/iscimport.py b/backend/review/management/commands/iscimport.py index 50b2c77be..f0f8c8e81 100644 --- a/backend/review/management/commands/iscimport.py +++ b/backend/review/management/commands/iscimport.py @@ -17,6 +17,7 @@ ) from review.import_utils.parse_sql import load_sql_dump from review.management.commands.clearcache import clear_cache +from review.management.commands.precompute_pcr_views import precompute_pcr_views from review.models import Review @@ -277,6 +278,7 @@ def handle(self, *args, **kwargs): print("Recomputing Section.has_reviews...") recompute_has_reviews() + precompute_pcr_views(verbose=True, is_new_data=True) print("Done.") return 0 diff --git a/backend/review/management/commands/precompute_pcr_views.py b/backend/review/management/commands/precompute_pcr_views.py new file mode 100644 index 000000000..f318b6dbd --- /dev/null +++ b/backend/review/management/commands/precompute_pcr_views.py @@ -0,0 +1,113 @@ +import logging + +from django.core.cache import cache +from django.core.management.base import BaseCommand +from django.db import transaction +from tqdm import tqdm + +from courses.models import Topic +from PennCourses.settings.base import CACHE_PREFIX +from review.models import CachedReviewResponse +from review.views import manual_course_reviews + + +def precompute_pcr_views(verbose=False, is_new_data=False): + if verbose: + print("Now precomputing PCR reviews.") + + objs_to_insert = [] + objs_to_update = [] + cache_deletes = set() + valid_reviews_in_db = total_reviews = 0 + + with transaction.atomic(): + # Mark all the topics as expired. + CachedReviewResponse.objects.all().update(expired=True) + cached_responses = CachedReviewResponse.objects.all() + topic_id_to_response_obj = {response.topic_id: response for response in cached_responses} + + # Iterate through all topics. + for topic in tqdm( + Topic.objects.all().select_related("most_recent").order_by("most_recent__semester"), + disable=not verbose, + ): + # get topic id + course_id_list, course_code_list = zip(*topic.courses.values_list("id", "full_code")) + topic_id = ".".join([str(id) for id in sorted(course_id_list)]) + total_reviews += 1 + + if topic_id in topic_id_to_response_obj: + # current topic id is already cached + valid_reviews_in_db += 1 + response_obj = topic_id_to_response_obj[topic_id] + response_obj.expired = False + + if is_new_data: + cache_deletes.add(CACHE_PREFIX + topic_id) + review_data = manual_course_reviews( + topic.most_recent.full_code, topic.most_recent.semester + ) + if not review_data: + logging.info( + f"Invalid review data for (" + f"topic_id={topic_id}," + f"course_code={course_code_list[0]}," + f"semester={topic.most_recent.semester})" + ) + continue + response_obj.response = review_data + objs_to_update.append(response_obj) + else: + # current topic id is not cached + review_data = manual_course_reviews( + topic.most_recent.full_code, topic.most_recent.semester + ) + if not review_data: + logging.info( + f"Invalid review data for (" + f"topic_id={topic_id}," + f"course_code={course_code_list[0]}," + f"semester={topic.most_recent.semester})" + ) + continue + + objs_to_insert.append( + CachedReviewResponse(topic_id=topic_id, response=review_data, expired=False) + ) + for course_code in course_code_list: + curr_topic_id = cache.get(CACHE_PREFIX + course_code) + if curr_topic_id: + cache_deletes.add(CACHE_PREFIX + curr_topic_id) + cache_deletes.add(CACHE_PREFIX + course_code) + + if verbose: + print( + f"{total_reviews} course reviews covered, " + f"{valid_reviews_in_db} of which were already in the database. " + f"{len(objs_to_insert)} course reviews were created. " + f"{len(objs_to_update)} course reviews were updated." + ) + + # Bulk create / update objects. + if verbose: + print("Creating and updating objects.") + CachedReviewResponse.objects.bulk_create(objs_to_insert) + CachedReviewResponse.objects.bulk_update(objs_to_update, ["response", "expired"]) + + # Bulk delete objects. + if verbose: + print("Deleting expired objects.") + CachedReviewResponse.objects.filter(expired=True).delete() + cache.delete_many(cache_deletes) + + +class Command(BaseCommand): + help = "Precompute PCR views for all topics" + + def add_arguments(self, parser): + parser.add_argument( + "--new_data", action="store_true", help="Include this flag to recalculate review data." + ) + + def handle(self, *args, **kwargs): + precompute_pcr_views(verbose=True, is_new_data=kwargs["new_data"]) diff --git a/backend/review/migrations/0006_cachedreviewresponse.py b/backend/review/migrations/0006_cachedreviewresponse.py new file mode 100644 index 000000000..3f2e6c8ec --- /dev/null +++ b/backend/review/migrations/0006_cachedreviewresponse.py @@ -0,0 +1,27 @@ +# Generated by Django 5.0.2 on 2024-10-26 03:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("review", "0005_review_comments"), + ] + + operations = [ + migrations.CreateModel( + name="CachedReviewResponse", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("topic_id", models.CharField(db_index=True, max_length=1000, unique=True)), + ("response", models.JSONField()), + ("expired", models.BooleanField(default=True)), + ], + ), + ] diff --git a/backend/review/models.py b/backend/review/models.py index dc4f63a52..294580cbc 100644 --- a/backend/review/models.py +++ b/backend/review/models.py @@ -94,6 +94,19 @@ def get_averages(topic_id, instructor_name=None, fields=None): ALL_FIELD_SLUGS = [x[2] for x in REVIEW_BIT_LABEL] +class CachedReviewResponse(models.Model): + """ + Represents a mapping from temp_topic_id (string-delimited list of sorted courses within a topic) + to a JSON object storing summarized course review data (all the data frontend uses to display + reviews). + """ + + # Using large string to account for topics with many course ids. + topic_id = models.CharField(max_length=1000, db_index=True, unique=True) + response = models.JSONField() + expired = models.BooleanField(default=True) + + class ReviewBit(models.Model): """ A single key/value pair associated with a review. Fields are things like "course_quality", diff --git a/backend/review/urls.py b/backend/review/urls.py index 5930156f0..506b09c96 100644 --- a/backend/review/urls.py +++ b/backend/review/urls.py @@ -18,7 +18,7 @@ urlpatterns = [ path( "course/", - cache_page(MONTH_IN_SECONDS)(course_reviews), + course_reviews, name="course-reviews", ), path( diff --git a/backend/review/views.py b/backend/review/views.py index 97dae075d..952839ab4 100644 --- a/backend/review/views.py +++ b/backend/review/views.py @@ -1,6 +1,7 @@ from collections import Counter, defaultdict from dateutil.tz import gettz +from django.core.cache import cache from django.db.models import F, Max, OuterRef, Q, Subquery, Value from django.http import Http404 from django.shortcuts import get_object_or_404 @@ -18,7 +19,7 @@ ) from courses.util import get_current_semester, get_or_create_add_drop_period, prettify_semester from PennCourses.docs_settings import PcxAutoSchema -from PennCourses.settings.base import TIME_ZONE, WAITLIST_DEPARTMENT_CODES +from PennCourses.settings.base import CACHE_PREFIX, TIME_ZONE, WAITLIST_DEPARTMENT_CODES from review.annotations import annotate_average_and_recent, review_averages from review.documentation import ( ACTIVITY_CHOICES, @@ -29,7 +30,7 @@ instructor_for_course_reviews_response_schema, instructor_reviews_response_schema, ) -from review.models import ALL_FIELD_SLUGS, Review +from review.models import ALL_FIELD_SLUGS, CachedReviewResponse, Review from review.util import ( aggregate_reviews, avg_and_recent_demand_plots, @@ -90,6 +91,10 @@ def extra_metrics_section_filters_pcr(current_semester=None): (~Q(course__title="") | ~Q(course__description="")) & ~Q(activity="REC") & ~Q(status="X") ) +HOUR_IN_SECONDS = 60 * 60 +DAY_IN_SECONDS = HOUR_IN_SECONDS * 24 +MONTH_IN_SECONDS = DAY_IN_SECONDS * 30 + @api_view(["GET"]) @schema( @@ -129,51 +134,84 @@ def extra_metrics_section_filters_pcr(current_semester=None): ) @permission_classes([IsAuthenticated]) def course_reviews(request, course_code, semester=None): + request_semester = request.GET.get("semester") + + topic_id = cache.get(CACHE_PREFIX + course_code) + if topic_id is None: + try: + recent_course = most_recent_course_from_code(course_code, request_semester) + except Course.DoesNotExist: + raise Http404() + topic = recent_course.topic + course_id_list = list(topic.courses.values_list("id")) + topic_id = ".".join([str(id[0]) for id in sorted(course_id_list)]) + cache.set(CACHE_PREFIX + course_code, topic_id, MONTH_IN_SECONDS) + + response = cache.get(CACHE_PREFIX + topic_id) + if response is None: + cached_response = CachedReviewResponse.objects.filter(topic_id=topic_id).first() + if cached_response is None: + response = manual_course_reviews(course_code, request_semester) + if not response: + raise Http404() + else: + response = cached_response.response + cache.set(CACHE_PREFIX + topic_id, response, MONTH_IN_SECONDS) + + return Response(response) + + +def most_recent_course_from_code(course_code, semester): + return ( + Course.objects.filter( + course_filters_pcr, + **( + {"topic__courses__full_code": course_code, "topic__courses__semester": semester} + if semester + else {"full_code": course_code} + ), + ) + .order_by("-semester")[:1] + .annotate( + branched_from_full_code=F("topic__branched_from__most_recent__full_code"), + branched_from_semester=F("topic__branched_from__most_recent__semester"), + ) + .select_related("topic__most_recent") + .get() + ) + + +def manual_course_reviews(course_code, request_semester): """ Get all reviews for the topic of a given course and other relevant information. Different aggregation views are provided, such as reviews spanning all semesters, only the most recent semester, and instructor-specific views. """ + semester = request_semester try: - semester = request.GET.get("semester") - course = ( - Course.objects.filter( - course_filters_pcr, - **( - {"topic__courses__full_code": course_code, "topic__courses__semester": semester} - if semester - else {"full_code": course_code} - ), - ) - .order_by("-semester")[:1] - .annotate( - branched_from_full_code=F("topic__branched_from__most_recent__full_code"), - branched_from_semester=F("topic__branched_from__most_recent__semester"), - ) - .select_related("topic__most_recent") - .get() - ) + course = most_recent_course_from_code(course_code, request_semester) except Course.DoesNotExist: - raise Http404() + return None topic = course.topic branched_from_full_code = course.branched_from_full_code branched_from_semester = course.branched_from_semester course = topic.most_recent course_code = course.full_code - aliases = course.crosslistings.values_list("full_code", flat=True) + aliases = list(course.crosslistings.values_list("full_code", flat=True)) - superseded = ( - Course.objects.filter( - course_filters_pcr, - full_code=course_code, + superseded = False + if semester: + max_semester = ( + Course.objects.filter( + course_filters_pcr, + full_code=course_code, + ) + .aggregate(max_semester=Max("semester")) + .get("max_semester") ) - .aggregate(max_semester=Max("semester")) - .get("max_semester") - > course.semester - if semester - else False - ) + if max_semester: + superseded = max_semester > course.semester last_offered_sem_if_superceded = course.semester if superseded else None topic_codes = list( @@ -248,22 +286,20 @@ def course_reviews(request, course_code, semester=None): course__topic=topic, ) - return Response( - { - "code": course["full_code"], - "last_offered_sem_if_superceded": last_offered_sem_if_superceded, - "name": course["title"], - "description": course["description"], - "aliases": aliases, - "historical_codes": historical_codes, - "latest_semester": course["semester"], - "num_sections": num_sections, - "num_sections_recent": num_sections_recent, - "instructors": instructors, - "registration_metrics": num_registration_metrics > 0, - **get_average_and_recent_dict_single(course), - } - ) + return { + "code": course["full_code"], + "last_offered_sem_if_superceded": last_offered_sem_if_superceded, + "name": course["title"], + "description": course["description"], + "aliases": aliases, + "historical_codes": historical_codes, + "latest_semester": course["semester"], + "num_sections": num_sections, + "num_sections_recent": num_sections_recent, + "instructors": instructors, + "registration_metrics": num_registration_metrics > 0, + **get_average_and_recent_dict_single(course), + } @api_view(["GET"]) diff --git a/backend/tests/courses/test_api.py b/backend/tests/courses/test_api.py index e0309dddd..e02066107 100644 --- a/backend/tests/courses/test_api.py +++ b/backend/tests/courses/test_api.py @@ -6,6 +6,7 @@ from django.test import RequestFactory, TestCase from django.urls import reverse from options.models import Option +from rest_framework import status from rest_framework.test import APIClient from alert.models import AddDropPeriod @@ -42,6 +43,14 @@ def set_semester(): AddDropPeriod(semester=TEST_SEMESTER).save() +class HealthTestCase(TestCase): + def test_health_check(self): + url = reverse("health") + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, {"message": "OK"}) + + class CourseListTestCase(TestCase): def setUp(self): set_semester() diff --git a/backend/tests/review/test_precompute_reviews.py b/backend/tests/review/test_precompute_reviews.py new file mode 100644 index 000000000..c49764515 --- /dev/null +++ b/backend/tests/review/test_precompute_reviews.py @@ -0,0 +1,219 @@ +from io import StringIO +from typing import Optional + +from django.core import management +from django.test import TestCase + +from courses.models import Course, Instructor, Topic +from courses.util import get_or_create_course_and_section +from review.import_utils.import_to_db import import_review +from review.models import CachedReviewResponse +from tests.courses.util import fill_course_soft_state + + +TEST1_SEMESTER = "2022C" +TEST2_SEMESTER = "2014A" +TEST3_SEMESTER = "2016C" +TEST4_SEMESTER = "2018C" +TEST5_SEMESTER = "2024A" +TEST6_SEMESTER = "2024B" + +INSTRUCTOR_ONE = "Instructor One" +INSTRUCTOR_TWO = "Instructor Two" +INSTRUCTOR_THREE = "Instructor Three" + + +def create_review(section_code, semester, instructor_name, bits, responses=100): + _, section, _, _ = get_or_create_course_and_section(section_code, semester) + instructor, _ = Instructor.objects.get_or_create(name=instructor_name) + section.instructors.add(instructor) + import_review(section, instructor, None, responses, None, bits, lambda x, y=None: None) + fill_course_soft_state() + + +class PrecomputePcrReviewsCommandTestCase(TestCase): + OPTION_COMMAND_NAME = "setoption" + COMMAND_NAME = "precompute_pcr_views" + + def set_runtime_option(self): + management.call_command( + self.OPTION_COMMAND_NAME, + "SEMESTER", + TEST1_SEMESTER, + stdout=self.out, + stderr=self.err, + ) + + def precompute_reviews(self, is_new_data): + command_args = [self.COMMAND_NAME] + if is_new_data: + command_args.append("--new_data") + + management.call_command( + *command_args, + stdout=self.out, + stderr=self.err, + ) + + def update_course_topic(self, old_course_code, new_course_code): + old_course = Course.objects.filter(full_code=old_course_code).first() + for new_course in Course.objects.filter(full_code=new_course_code): + old_course_topic = old_course.topic + new_course_topic = new_course.topic + new_course.topic = old_course.topic + old_course_topic.most_recent = new_course + new_course.save() + old_course_topic.save() + + if not new_course_topic.courses.exists(): + Topic.objects.filter(id=new_course_topic.id).first().delete() + + def setUp(self): + self.out = StringIO() + self.err = StringIO() + + # Same Course, Originally Different Topics + create_review("CIS-120-001", TEST1_SEMESTER, INSTRUCTOR_ONE, {"instructor_quality": 4}) + + create_review("CIS-120-002", TEST2_SEMESTER, INSTRUCTOR_TWO, {"instructor_quality": 2}) + + create_review("CIS-1200-001", TEST3_SEMESTER, INSTRUCTOR_THREE, {"instructor_quality": 0}) + + # Individual Course + create_review("CIS-1210-003", TEST3_SEMESTER, INSTRUCTOR_THREE, {"instructor_quality": 2}) + + # Courses to Switch Topics + create_review("AFRC-1500-001", TEST1_SEMESTER, INSTRUCTOR_ONE, {"instructor_quality": 1}) + create_review("ANTH-1500-002", TEST2_SEMESTER, INSTRUCTOR_TWO, {"instructor_quality": 3}) + create_review("MUSC-1500-001", TEST3_SEMESTER, INSTRUCTOR_THREE, {"instructor_quality": 1}) + self.update_course_topic("AFRC-1500", "ANTH-1500") + + self.set_runtime_option() + self.precompute_reviews(is_new_data=False) + + def add_new_review_data(self): + create_review("CIS-120-002", TEST5_SEMESTER, INSTRUCTOR_TWO, {"instructor_quality": 3}) + create_review("CIS-1200-001", TEST6_SEMESTER, INSTRUCTOR_THREE, {"instructor_quality": 0}) + create_review("CIS-1210-004", TEST5_SEMESTER, INSTRUCTOR_THREE, {"instructor_quality": 4}) + create_review("MUSC-1500-003", TEST6_SEMESTER, INSTRUCTOR_THREE, {"instructor_quality": 0}) + create_review("AFRC-1500-002", TEST5_SEMESTER, INSTRUCTOR_ONE, {"instructor_quality": 2}) + create_review("IPD-5150-101", TEST5_SEMESTER, INSTRUCTOR_ONE, {"instructor_quality": 3}) + self.update_course_topic("AFRC-1500", "ANTH-1500") + + def get_cached_review_with_courses(self, course_codes) -> Optional[CachedReviewResponse]: + course_ids = [] + for course_code in course_codes: + course_ids.extend( + list(Course.objects.filter(full_code=course_code).values_list("id", flat=True)) + ) + + topic_id = ".".join([str(id) for id in sorted(course_ids)]) + return CachedReviewResponse.objects.filter(topic_id=topic_id).first() + + def test_same_data_same_topics(self): + self.precompute_reviews(is_new_data=False) + + # Test Number of Topics + self.assertEqual(CachedReviewResponse.objects.count(), 5) + + # Test CachedReview Topic Id Construction + cr1 = self.get_cached_review_with_courses(["CIS-120"]) + self.assertIsNotNone(cr1) + cr2 = self.get_cached_review_with_courses(["CIS-1200"]) + self.assertIsNotNone(cr2) + cr3 = self.get_cached_review_with_courses(["CIS-1210"]) + self.assertIsNotNone(cr3) + cr4 = self.get_cached_review_with_courses(["AFRC-1500", "ANTH-1500"]) + self.assertIsNotNone(cr4) + cr5 = self.get_cached_review_with_courses(["MUSC-1500"]) + self.assertIsNotNone(cr5) + + # Tests Cached Review Average Values + self.assertEqual(cr1.response["average_reviews"]["rInstructorQuality"], 3) + self.assertEqual(cr2.response["average_reviews"]["rInstructorQuality"], 0) + self.assertEqual(cr3.response["average_reviews"]["rInstructorQuality"], 2) + self.assertEqual(cr4.response["average_reviews"]["rInstructorQuality"], 2) + self.assertEqual(cr5.response["average_reviews"]["rInstructorQuality"], 1) + + def test_same_data_new_topics(self): + # Change Topics + self.update_course_topic("CIS-120", "CIS-1200") + self.update_course_topic("MUSC-1500", "AFRC-1500") + self.precompute_reviews(is_new_data=False) + + # Test Number of Topics + self.assertEqual(CachedReviewResponse.objects.count(), 4) + + # Test CachedReview Topic Id Construction + cr1 = self.get_cached_review_with_courses(["CIS-120", "CIS-1200"]) + self.assertIsNotNone(cr1) + cr2 = self.get_cached_review_with_courses(["CIS-1210"]) + self.assertIsNotNone(cr2) + cr3 = self.get_cached_review_with_courses(["AFRC-1500", "MUSC-1500"]) + self.assertIsNotNone(cr3) + cr4 = self.get_cached_review_with_courses(["ANTH-1500"]) + self.assertIsNotNone(cr4) + + # Tests Cached Review Average Values + self.assertEqual(cr1.response["average_reviews"]["rInstructorQuality"], 2) + self.assertEqual(cr2.response["average_reviews"]["rInstructorQuality"], 2) + self.assertEqual(cr3.response["average_reviews"]["rInstructorQuality"], 1) + self.assertEqual(cr4.response["average_reviews"]["rInstructorQuality"], 3) + + def test_new_data_same_topics(self): + self.add_new_review_data() + self.precompute_reviews(is_new_data=True) + + # Test Number of Topics + self.assertEqual(CachedReviewResponse.objects.count(), 6) + + # Test CachedReview Topic Id Construction + cr1 = self.get_cached_review_with_courses(["CIS-120"]) + self.assertIsNotNone(cr1) + cr2 = self.get_cached_review_with_courses(["CIS-1200"]) + self.assertIsNotNone(cr2) + cr3 = self.get_cached_review_with_courses(["CIS-1210"]) + self.assertIsNotNone(cr3) + cr4 = self.get_cached_review_with_courses(["AFRC-1500", "ANTH-1500"]) + self.assertIsNotNone(cr4) + cr5 = self.get_cached_review_with_courses(["MUSC-1500"]) + self.assertIsNotNone(cr5) + cr6 = self.get_cached_review_with_courses(["IPD-5150"]) + self.assertIsNotNone(cr6) + + # Tests Cached Review Average Values + self.assertEqual(cr1.response["average_reviews"]["rInstructorQuality"], 3) + self.assertEqual(cr2.response["average_reviews"]["rInstructorQuality"], 0) + self.assertEqual(cr3.response["average_reviews"]["rInstructorQuality"], 3) + self.assertEqual(cr4.response["average_reviews"]["rInstructorQuality"], 2) + self.assertEqual(cr5.response["average_reviews"]["rInstructorQuality"], 0.5) + self.assertEqual(cr6.response["average_reviews"]["rInstructorQuality"], 3) + + def test_new_data_new_topics(self): + # Change Topics + self.add_new_review_data() + self.update_course_topic("CIS-120", "CIS-1200") + self.update_course_topic("MUSC-1500", "AFRC-1500") + self.precompute_reviews(is_new_data=False) + + # Test Number of Topics + self.assertEqual(CachedReviewResponse.objects.count(), 5) + + # Test CachedReview Topic Id Construction + cr1 = self.get_cached_review_with_courses(["CIS-120", "CIS-1200"]) + self.assertIsNotNone(cr1) + cr2 = self.get_cached_review_with_courses(["CIS-1210"]) + self.assertIsNotNone(cr2) + cr3 = self.get_cached_review_with_courses(["AFRC-1500", "MUSC-1500"]) + self.assertIsNotNone(cr3) + cr4 = self.get_cached_review_with_courses(["ANTH-1500"]) + self.assertIsNotNone(cr4) + cr5 = self.get_cached_review_with_courses(["IPD-5150"]) + self.assertIsNotNone(cr5) + + # Tests Cached Review Average Values + self.assertEqual(cr1.response["average_reviews"]["rInstructorQuality"], 1.8) + self.assertEqual(cr2.response["average_reviews"]["rInstructorQuality"], 3) + self.assertEqual(cr3.response["average_reviews"]["rInstructorQuality"], 1) + self.assertEqual(cr4.response["average_reviews"]["rInstructorQuality"], 3) + self.assertEqual(cr5.response["average_reviews"]["rInstructorQuality"], 3) diff --git a/frontend/plan/actions/index.js b/frontend/plan/actions/index.js index 1d60f66c8..2d0b7cae2 100644 --- a/frontend/plan/actions/index.js +++ b/frontend/plan/actions/index.js @@ -564,11 +564,38 @@ const rateLimitedFetch = (url, init) => } }); +export const deduplicateCourseMeetings = (course) => { + const deduplicatedCourse = { + ...course, + sections: course.sections.map((section) => { + const meetings = []; + + section.meetings.forEach((meeting) => { + const exists = meetings.some( + (existingMeeting) => + existingMeeting.day === meeting.day && + existingMeeting.start === meeting.start && + existingMeeting.end === meeting.end + ); + + if (!exists) { + meetings.push(meeting); + } + }); + + return { ...section, meetings }; + }), + }; + + return deduplicatedCourse; +}; + export function fetchCourseDetails(courseId) { return (dispatch) => { dispatch(updateCourseInfoRequest()); doAPIRequest(`/base/current/courses/${courseId}/`) .then((res) => res.json()) + .then((data) => deduplicateCourseMeetings(data)) .then((course) => dispatch(updateCourseInfo(course))) .catch((error) => dispatch(sectionInfoSearchError(error))); }; @@ -583,6 +610,7 @@ export function fetchCourseDetails(courseId) { export const fetchBackendSchedules = (onComplete) => (dispatch) => { doAPIRequest("/plan/schedules/") .then((res) => res.json()) + .then((data) => data.map((course) => deduplicateCourseMeetings(course))) .then((schedules) => { onComplete(schedules); }) diff --git a/k8s/main.ts b/k8s/main.ts index 8b73e39bd..ad6d86c16 100644 --- a/k8s/main.ts +++ b/k8s/main.ts @@ -1,114 +1,147 @@ -import { Construct } from 'constructs'; -import { App } from 'cdk8s'; -import { CronJob, DjangoApplication, PennLabsChart, ReactApplication, RedisApplication } from '@pennlabs/kittyhawk'; - -const cronTime = require('cron-time-generator'); +import { Construct } from 'constructs' +import { App } from 'cdk8s' +import { + CronJob, + DjangoApplication, + PennLabsChart, + ReactApplication, + RedisApplication, +} from '@pennlabs/kittyhawk' + +const cronTime = require('cron-time-generator') export class MyChart extends PennLabsChart { - constructor(scope: Construct) { - super(scope); - - const backendImage = 'pennlabs/penn-courses-backend'; - const secret = 'penn-courses'; - const ingressProps = { - annotations: { - ['ingress.kubernetes.io/content-security-policy']: "frame-ancestors 'none';", - ["ingress.kubernetes.io/protocol"]: "https", - ["traefik.ingress.kubernetes.io/router.middlewares"]: "default-redirect-http@kubernetescrd" - } - } - - new RedisApplication(this, 'redis', { - persistData: true, - }); - - new DjangoApplication(this, 'celery', { - deployment: { - image: backendImage, - secret, - cmd: ['celery', 'worker', '-A', 'PennCourses', '-Q', 'alerts,celery', '-linfo'], - }, - djangoSettingsModule: 'PennCourses.settings.production', - }); - - new DjangoApplication(this, 'backend', { - deployment: { - image: backendImage, - secret, - replicas: 5, - }, - djangoSettingsModule: 'PennCourses.settings.production', - ingressProps, - domains: [{ host: 'penncourseplan.com', paths: ["/api", "/admin", "/accounts", "/assets"] }, - { host: 'penncoursealert.com', paths: ["/api", "/admin", "/accounts", "/assets", "/webhook"] }, - { host: 'penncoursereview.com', paths: ["/api", "/admin", "/accounts", "/assets"] }, - { host: 'penndegreeplan.com', paths: ["/api", "/admin", "/accounts", "/assets"] }] - }); - - new DjangoApplication(this, 'backend-asgi', { - deployment: { - image: backendImage, - cmd: ['/usr/local/bin/asgi-run'], - secret, - replicas: 1, - }, - djangoSettingsModule: 'PennCourses.settings.production', - ingressProps, - domains: [{ host: 'penncoursereview.com', paths: ["/api/ws"] }], - }); - - new ReactApplication(this, 'landing', { - deployment: { - image: 'pennlabs/pcx-landing', - }, - domain: { host: 'penncourses.org', paths: ['/'] }, - }); - - new ReactApplication(this, 'plan', { - deployment: { - image: 'pennlabs/pcp-frontend', - }, - domain: { host: 'penncourseplan.com', paths: ['/'] }, - }); - - new ReactApplication(this, 'alert', { - deployment: { - image: 'pennlabs/pca-frontend', - }, - domain: { host: 'penncoursealert.com', paths: ['/'] }, - }); - - new ReactApplication(this, 'review', { - deployment: { - image: 'pennlabs/pcr-frontend', - }, - domain: { host: 'penncoursereview.com', paths: ['/'] }, - }); - - new ReactApplication(this, 'degree', { - deployment: { - image: 'pennlabs/pdp-frontend', - }, - domain: { host: 'penndegreeplan.com', paths: ['/'] }, - }); - - new CronJob(this, 'load-courses', { - schedule: cronTime.everyDayAt(3), - image: backendImage, - secret, - cmd: ['python', 'manage.py', 'registrarimport'], - }); - - new CronJob(this, 'report-stats', { - schedule: cronTime.everyDayAt(20), - image: backendImage, - secret, - cmd: ['python', 'manage.py', 'alertstats', '1', '--slack'], - }); - - } + constructor(scope: Construct) { + super(scope) + + const backendImage = 'pennlabs/penn-courses-backend' + const secret = 'penn-courses' + const ingressProps = { + annotations: { + ['ingress.kubernetes.io/content-security-policy']: + "frame-ancestors 'none';", + ['ingress.kubernetes.io/protocol']: 'https', + ['traefik.ingress.kubernetes.io/router.middlewares']: + 'default-redirect-http@kubernetescrd', + }, + } + + new RedisApplication(this, 'redis', { + persistData: false, + }) + + new DjangoApplication(this, 'celery', { + deployment: { + image: backendImage, + secret, + cmd: [ + 'celery', + '-A', + 'PennCourses', + 'worker', + '-Q', + 'alerts,celery', + '-linfo', + ], + }, + djangoSettingsModule: 'PennCourses.settings.production', + }) + + new DjangoApplication(this, 'backend', { + deployment: { + image: backendImage, + secret, + replicas: 2, + }, + djangoSettingsModule: 'PennCourses.settings.production', + ingressProps, + domains: [ + { + host: 'penncourseplan.com', + paths: ['/api', '/admin', '/accounts', '/assets'], + }, + { + host: 'penncoursealert.com', + paths: ['/api', '/admin', '/accounts', '/assets', '/webhook'], + }, + { + host: 'penncoursereview.com', + paths: ['/api', '/admin', '/accounts', '/assets'], + }, + { + host: 'penndegreeplan.com', + paths: ['/api', '/admin', '/accounts', '/assets'], + }, + { + host: 'penncourses.org', + paths: ['/api', '/admin', '/accounts', '/assets'], + }, + ], + }) + + new DjangoApplication(this, 'backend-asgi', { + deployment: { + image: backendImage, + cmd: ['/usr/local/bin/asgi-run'], + secret, + replicas: 1, + }, + djangoSettingsModule: 'PennCourses.settings.production', + ingressProps, + domains: [{ host: 'penncoursereview.com', paths: ['/api/ws'] }], + }) + + new ReactApplication(this, 'landing', { + deployment: { + image: 'pennlabs/pcx-landing', + }, + domain: { host: 'penncourses.org', paths: ['/'] }, + }) + + new ReactApplication(this, 'plan', { + deployment: { + image: 'pennlabs/pcp-frontend', + }, + domain: { host: 'penncourseplan.com', paths: ['/'] }, + }) + + new ReactApplication(this, 'alert', { + deployment: { + image: 'pennlabs/pca-frontend', + }, + domain: { host: 'penncoursealert.com', paths: ['/'] }, + }) + + new ReactApplication(this, 'review', { + deployment: { + image: 'pennlabs/pcr-frontend', + }, + domain: { host: 'penncoursereview.com', paths: ['/'] }, + }) + + new ReactApplication(this, 'degree', { + deployment: { + image: 'pennlabs/pdp-frontend', + }, + domain: { host: 'penndegreeplan.com', paths: ['/'] }, + }) + + new CronJob(this, 'load-courses', { + schedule: cronTime.everyDayAt(3), + image: backendImage, + secret, + cmd: ['python', 'manage.py', 'registrarimport'], + }) + + new CronJob(this, 'report-stats', { + schedule: cronTime.everyDayAt(20), + image: backendImage, + secret, + cmd: ['python', 'manage.py', 'alertstats', '1', '--slack'], + }) + } } -const app = new App(); -new MyChart(app); -app.synth(); +const app = new App() +new MyChart(app) +app.synth() diff --git a/k8s/package.json b/k8s/package.json index e783dcd5a..f39d8b7d7 100644 --- a/k8s/package.json +++ b/k8s/package.json @@ -1,29 +1,32 @@ { - "name": "k8s", - "version": "1.0.0", - "main": "main.js", - "types": "main.ts", - "license": "Apache-2.0", - "private": true, - "scripts": { - "import": "cdk8s import", - "synth": "cdk8s synth", - "compile": "tsc", - "watch": "tsc -w", - "build": "npm run compile && npm run synth", - "upgrade": "npm i cdk8s@latest cdk8s-cli@latest", - "upgrade:next": "npm i cdk8s@next cdk8s-cli@next" - }, - "dependencies": { - "@pennlabs/kittyhawk": "^1.1.9", - "cdk8s": "^2.2.63", - "constructs": "^10.0.119" - }, - "devDependencies": { - "@types/jest": "^26.0.24", - "@types/node": "^14.18.12", - "jest": "^26.6.3", - "ts-jest": "^26.5.6", - "typescript": "^4.6.3" - } + "name": "k8s", + "version": "1.0.0", + "main": "main.js", + "types": "main.ts", + "license": "Apache-2.0", + "private": true, + "prettier": "@esinx/prettier-config", + "scripts": { + "import": "cdk8s import", + "synth": "cdk8s synth", + "compile": "tsc", + "watch": "tsc -w", + "build": "npm run compile && npm run synth", + "upgrade": "npm i cdk8s@latest cdk8s-cli@latest", + "upgrade:next": "npm i cdk8s@next cdk8s-cli@next" + }, + "dependencies": { + "@pennlabs/kittyhawk": "^1.1.9", + "cdk8s": "^2.2.63", + "constructs": "^10.0.119" + }, + "devDependencies": { + "@esinx/prettier-config": "^1.0.0-3", + "@types/jest": "^26.0.24", + "@types/node": "^14.18.12", + "jest": "^26.6.3", + "prettier": "^3.3.3", + "ts-jest": "^26.5.6", + "typescript": "^4.6.3" + } } diff --git a/k8s/tsconfig.json b/k8s/tsconfig.json index 4289e5aa5..7014510c0 100644 --- a/k8s/tsconfig.json +++ b/k8s/tsconfig.json @@ -1,33 +1,26 @@ { - "compilerOptions": { - "alwaysStrict": true, - "charset": "utf8", - "declaration": true, - "experimentalDecorators": true, - "inlineSourceMap": true, - "inlineSources": true, - "lib": [ - "es2016" - ], - "module": "CommonJS", - "noEmitOnError": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "noImplicitReturns": true, - "noImplicitThis": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "resolveJsonModule": true, - "strict": true, - "strictNullChecks": true, - "strictPropertyInitialization": true, - "stripInternal": true, - "target": "ES2017" - }, - "include": [ - "**/*.ts" - ], - "exclude": [ - "node_modules" - ] + "compilerOptions": { + "alwaysStrict": true, + "declaration": true, + "experimentalDecorators": true, + "inlineSourceMap": true, + "inlineSources": true, + "lib": ["es2016"], + "module": "CommonJS", + "noEmitOnError": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "strict": true, + "strictNullChecks": true, + "strictPropertyInitialization": true, + "stripInternal": true, + "target": "ES2017" + }, + "include": ["**/*.ts"], + "exclude": ["node_modules"] } diff --git a/k8s/yarn.lock b/k8s/yarn.lock index e18e108e7..bdc409438 100644 --- a/k8s/yarn.lock +++ b/k8s/yarn.lock @@ -359,6 +359,11 @@ exec-sh "^0.3.2" minimist "^1.2.0" +"@esinx/prettier-config@^1.0.0-3": + version "1.0.0-3" + resolved "https://registry.yarnpkg.com/@esinx/prettier-config/-/prettier-config-1.0.0-3.tgz#985bf542b3a914cba6e57d4907d6520b1423ae4d" + integrity sha512-Y/cI8Qia6piZca5DCRclsrjRXjIYNZ6GUBySRgvLlN5WliAPe0oiDFmaJJDdSoXtPQYijkPBstIUJR5gylU+wg== + "@istanbuljs/load-nyc-config@^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" @@ -3577,6 +3582,11 @@ prelude-ls@~1.1.2: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= +prettier@^3.3.3: + version "3.3.3" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.3.tgz#30c54fe0be0d8d12e6ae61dbb10109ea00d53105" + integrity sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew== + pretty-format@^26.0.0, pretty-format@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-26.6.2.tgz#e35c2705f14cb7fe2fe94fa078345b444120fc93"