From 43daeaa73f1262f77c9b3b100644fb671e66c6a8 Mon Sep 17 00:00:00 2001 From: Ashley Zhang <69987606+ashleyzhang01@users.noreply.github.com> Date: Wed, 13 Nov 2024 16:16:47 -0800 Subject: [PATCH] Add mypy (#322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🌷 add mypy * 🌌 fix mypy errors in portal * πŸ‘₯user and sublet typing * 🍽️ fix dining typing * 🏘️ fix subletting tests typing * πŸ“Š penndata typing errors * πŸ“š gsr booking type fixes * πŸ§Žβ€β™€οΈOMFG NO WAY NO MORE TYPE ERRORS * 🧹 clean up types files * πŸ’― fix tests * πŸŒ‰ extend pre-commit rules --- .pre-commit-config.yaml | 27 +- backend/Pipfile | 2 + backend/Pipfile.lock | 397 ++++++++++-------- backend/dining/api_wrapper.py | 2 + backend/dining/migrations/0001_initial.py | 4 +- ...002_diningitem_diningstation_diningmenu.py | 4 +- backend/dining/migrations/0003_venue_name.py | 10 +- ...move_diningtransaction_profile_and_more.py | 25 +- ...gens_diningitem_nutrition_info_and_more.py | 4 +- ...006_remove_diningmenu_stations_and_more.py | 9 +- backend/dining/models.py | 44 +- backend/dining/views.py | 14 +- backend/gsr_booking/admin.py | 16 +- backend/gsr_booking/api_wrapper.py | 174 ++++---- .../management/commands/change_group.py | 3 +- .../management/commands/get_reservations.py | 3 +- .../management/commands/individual_usage.py | 3 +- .../gsr_booking/migrations/0001_initial.py | 18 +- .../0001_squashed_0011_merge_20200418_2009.py | 44 +- .../migrations/0002_auto_20210129_1527.py | 10 +- .../migrations/0004_alter_gsr_lid.py | 10 +- .../migrations/0005_reservation.py | 2 +- .../migrations/0005_usersearchindex.py | 8 +- .../migrations/0006_auto_20200207_2126.py | 4 +- .../migrations/0006_auto_20211024_1231.py | 14 +- .../migrations/0006_gsrbookingcredentials.py | 15 +- .../0007_delete_gsrbookingcredentials.py | 10 +- .../migrations/0008_auto_20200202_1218.py | 2 +- .../migrations/0008_auto_20211112_1657.py | 17 +- .../migrations/0009_auto_20200202_1232.py | 6 +- ...emove_groupmembership_username_and_more.py | 9 +- ...emove_gsrbooking_reminder_sent_and_more.py | 13 +- .../0011_alter_reservation_group.py | 6 +- .../gsr_booking/migrations/0012_gsr_in_use.py | 10 +- backend/gsr_booking/models.py | 118 +++--- backend/gsr_booking/serializers.py | 59 +-- backend/gsr_booking/urls.py | 4 +- backend/gsr_booking/views.py | 96 ++--- backend/laundry/api_wrapper.py | 2 +- .../migrations/0002_auto_20210321_1105.py | 6 +- backend/laundry/models.py | 26 +- backend/laundry/views.py | 36 +- backend/manage.py | 2 +- backend/penndata/admin.py | 6 +- .../commands/get_college_house_events.py | 2 +- .../commands/get_fitness_snapshot.py | 16 +- .../commands/get_penn_today_events.py | 109 ++--- .../management/commands/get_venture_events.py | 2 +- .../management/commands/get_wharton_events.py | 10 +- backend/penndata/migrations/0001_initial.py | 2 +- .../penndata/migrations/0002_homepageorder.py | 6 +- .../migrations/0003_analyticsevent.py | 2 +- .../migrations/0004_analyticsevent_data.py | 6 +- .../0005_fitnessroom_fitnesssnapshot.py | 4 +- .../0006_fitnesssnapshot_capacity.py | 10 +- .../migrations/0007_fitnessroom_image_url.py | 6 +- .../penndata/migrations/0008_calendarevent.py | 6 +- .../migrations/0009_auto_20240223_1820.py | 41 +- .../migrations/0010_auto_20240228_0150.py | 21 +- ...lter_event_event_type_alter_event_start.py | 8 +- .../migrations/0012_alter_event_event_type.py | 6 +- backend/penndata/models.py | 69 +-- backend/penndata/serializers.py | 7 +- backend/penndata/views.py | 73 ++-- backend/pennmobile/admin.py | 25 +- backend/pennmobile/celery.py | 3 +- backend/pennmobile/settings/base.py | 4 +- backend/pennmobile/settings/production.py | 2 +- backend/pennmobile/test_runner.py | 11 +- backend/pennmobile/urls.py | 14 +- backend/portal/admin.py | 32 +- backend/portal/logic.py | 57 ++- .../commands/load_target_populations.py | 11 +- .../management/commands/polls_populate.py | 75 +--- backend/portal/migrations/0001_initial.py | 4 +- .../migrations/0002_auto_20211003_2225.py | 13 +- .../0003_alter_targetpopulation_kind.py | 6 +- backend/portal/migrations/0004_post.py | 2 +- .../migrations/0005_auto_20211231_1558.py | 34 +- .../migrations/0006_auto_20220112_1529.py | 35 +- backend/portal/migrations/0007_post_status.py | 6 +- .../migrations/0008_alter_post_image_url.py | 6 +- .../0009_rename_image_url_post_image.py | 12 +- .../migrations/0010_remove_post_image.py | 11 +- backend/portal/migrations/0011_post_image.py | 6 +- .../migrations/0012_remove_post_image.py | 11 +- backend/portal/migrations/0013_post_image.py | 6 +- .../migrations/0014_alter_post_post_url.py | 6 +- .../migrations/0015_auto_20240226_2236.py | 18 +- backend/portal/models.py | 96 +++-- backend/portal/permissions.py | 65 +-- backend/portal/serializers.py | 87 ++-- backend/portal/views.py | 51 +-- backend/setup.cfg | 3 +- backend/sublet/admin.py | 6 +- backend/sublet/migrations/0001_initial.py | 8 +- .../migrations/0002_auto_20240209_1649.py | 19 +- .../migrations/0003_alter_sublet_baths.py | 6 +- .../0004_alter_sublet_external_link.py | 6 +- backend/sublet/models.py | 76 ++-- backend/sublet/permissions.py | 41 +- backend/sublet/serializers.py | 29 +- backend/sublet/views.py | 69 ++- backend/tests/dining/test_load_venues.py | 4 +- backend/tests/dining/test_views.py | 52 ++- backend/tests/gsr_booking/test_gsr_booking.py | 114 ++--- backend/tests/gsr_booking/test_gsr_views.py | 58 +-- backend/tests/gsr_booking/test_gsr_wrapper.py | 66 +-- backend/tests/laundry/test_api_wrapper.py | 12 +- backend/tests/laundry/test_commands.py | 10 +- backend/tests/laundry/test_models.py | 10 +- backend/tests/laundry/test_views.py | 43 +- backend/tests/penndata/test_views.py | 101 +++-- backend/tests/portal/test_permissions.py | 55 +-- backend/tests/portal/test_polls.py | 133 +++--- backend/tests/portal/test_posts.py | 65 +-- backend/tests/sublet/test_permissions.py | 71 ++-- backend/tests/sublet/test_sublets.py | 122 +++--- backend/tests/user/test_notifs.py | 107 ++--- backend/tests/user/test_user.py | 29 +- backend/tests/utils/test_email.py | 29 +- backend/tests/utils/test_r_request.py | 27 +- .../user/management/commands/clear_cache.py | 3 +- .../user/management/commands/profile_info.py | 3 +- .../0002_profile_laundry_preferences.py | 7 +- .../0003_profile_dining_preferences.py | 7 +- .../migrations/0004_auto_20210324_1851.py | 7 +- .../migrations/0005_auto_20211003_2240.py | 18 +- .../0007_alter_notificationsetting_service.py | 4 +- .../0008_remove_notificationtoken_dev.py | 11 +- .../0009_profile_fitness_preferences.py | 2 +- backend/user/models.py | 33 +- backend/user/notifications.py | 45 +- backend/user/serializers.py | 9 +- backend/user/views.py | 38 +- backend/utils/email.py | 37 +- backend/utils/r_request.py | 4 +- backend/utils/types.py | 75 ++++ 138 files changed, 2006 insertions(+), 2077 deletions(-) create mode 100644 backend/utils/types.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index aec50834..c6022dc8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,9 +3,34 @@ repos: rev: v1.1-mobile-backend hooks: - id: black - args: [-l100] + args: [-l100, --config, backend/pyproject.toml] - id: isort args: [] - id: flake8 args: [--config, backend/setup.cfg] - id: detect-private-key + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.8.0 + hooks: + - id: mypy + additional_dependencies: + - types-requests + - types-python-dateutil + - django-stubs + - djangorestframework-stubs + - types-PyYAML + - types-redis + - types-pytz + files: ^backend/ + exclude: ^backend/.*/migrations/.*$ + args: [ + --ignore-missing-imports, + --disallow-untyped-defs, + --check-untyped-defs, + --warn-redundant-casts, + --no-implicit-optional, + --strict-optional, + --warn-unused-ignores, + --disallow-incomplete-defs, + ] diff --git a/backend/Pipfile b/backend/Pipfile index d2c29862..43a7aff5 100644 --- a/backend/Pipfile +++ b/backend/Pipfile @@ -48,6 +48,8 @@ webdriver-manager = "*" pre-commit = "*" alt-profanity-check = "*" inflection = "*" +types-redis = "*" +types-pytz = "*" [requires] python_version = "3.11" diff --git a/backend/Pipfile.lock b/backend/Pipfile.lock index e1ee3f2c..e1b32e1e 100644 --- a/backend/Pipfile.lock +++ b/backend/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "f89acb57e8e31f7e049ea1c699698737a30b50d260d20fea7f07d19bcb56c00d" + "sha256": "e8c3871357a67fa0783821e10e9633bd4abefb07550453f4c4fb6918ebfb8dfb" }, "pipfile-spec": 6, "requires": { @@ -109,76 +109,76 @@ }, "cffi": { "hashes": [ - "sha256:011aff3524d578a9412c8b3cfaa50f2c0bd78e03eb7af7aa5e0df59b158efb2f", - "sha256:0a048d4f6630113e54bb4b77e315e1ba32a5a31512c31a273807d0027a7e69ab", - "sha256:0bb15e7acf8ab35ca8b24b90af52c8b391690ef5c4aec3d31f38f0d37d2cc499", - "sha256:0d46ee4764b88b91f16661a8befc6bfb24806d885e27436fdc292ed7e6f6d058", - "sha256:0e60821d312f99d3e1569202518dddf10ae547e799d75aef3bca3a2d9e8ee693", - "sha256:0fdacad9e0d9fc23e519efd5ea24a70348305e8d7d85ecbb1a5fa66dc834e7fb", - "sha256:14b9cbc8f7ac98a739558eb86fabc283d4d564dafed50216e7f7ee62d0d25377", - "sha256:17c6d6d3260c7f2d94f657e6872591fe8733872a86ed1345bda872cfc8c74885", - "sha256:1a2ddbac59dc3716bc79f27906c010406155031a1c801410f1bafff17ea304d2", - "sha256:2404f3de742f47cb62d023f0ba7c5a916c9c653d5b368cc966382ae4e57da401", - "sha256:24658baf6224d8f280e827f0a50c46ad819ec8ba380a42448e24459daf809cf4", - "sha256:24aa705a5f5bd3a8bcfa4d123f03413de5d86e497435693b638cbffb7d5d8a1b", - "sha256:2770bb0d5e3cc0e31e7318db06efcbcdb7b31bcb1a70086d3177692a02256f59", - "sha256:331ad15c39c9fe9186ceaf87203a9ecf5ae0ba2538c9e898e3a6967e8ad3db6f", - "sha256:3aa9d43b02a0c681f0bfbc12d476d47b2b2b6a3f9287f11ee42989a268a1833c", - "sha256:41f4915e09218744d8bae14759f983e466ab69b178de38066f7579892ff2a555", - "sha256:4304d4416ff032ed50ad6bb87416d802e67139e31c0bde4628f36a47a3164bfa", - "sha256:435a22d00ec7d7ea533db494da8581b05977f9c37338c80bc86314bec2619424", - "sha256:45f7cd36186db767d803b1473b3c659d57a23b5fa491ad83c6d40f2af58e4dbb", - "sha256:48b389b1fd5144603d61d752afd7167dfd205973a43151ae5045b35793232aa2", - "sha256:4e67d26532bfd8b7f7c05d5a766d6f437b362c1bf203a3a5ce3593a645e870b8", - "sha256:516a405f174fd3b88829eabfe4bb296ac602d6a0f68e0d64d5ac9456194a5b7e", - "sha256:5ba5c243f4004c750836f81606a9fcb7841f8874ad8f3bf204ff5e56332b72b9", - "sha256:5bdc0f1f610d067c70aa3737ed06e2726fd9d6f7bfee4a351f4c40b6831f4e82", - "sha256:6107e445faf057c118d5050560695e46d272e5301feffda3c41849641222a828", - "sha256:6327b572f5770293fc062a7ec04160e89741e8552bf1c358d1a23eba68166759", - "sha256:669b29a9eca6146465cc574659058ed949748f0809a2582d1f1a324eb91054dc", - "sha256:6ce01337d23884b21c03869d2f68c5523d43174d4fc405490eb0091057943118", - "sha256:6d872186c1617d143969defeadac5a904e6e374183e07977eedef9c07c8953bf", - "sha256:6f76a90c345796c01d85e6332e81cab6d70de83b829cf1d9762d0a3da59c7932", - "sha256:70d2aa9fb00cf52034feac4b913181a6e10356019b18ef89bc7c12a283bf5f5a", - "sha256:7cbc78dc018596315d4e7841c8c3a7ae31cc4d638c9b627f87d52e8abaaf2d29", - "sha256:856bf0924d24e7f93b8aee12a3a1095c34085600aa805693fb7f5d1962393206", - "sha256:8a98748ed1a1df4ee1d6f927e151ed6c1a09d5ec21684de879c7ea6aa96f58f2", - "sha256:93a7350f6706b31f457c1457d3a3259ff9071a66f312ae64dc024f049055f72c", - "sha256:964823b2fc77b55355999ade496c54dde161c621cb1f6eac61dc30ed1b63cd4c", - "sha256:a003ac9edc22d99ae1286b0875c460351f4e101f8c9d9d2576e78d7e048f64e0", - "sha256:a0ce71725cacc9ebf839630772b07eeec220cbb5f03be1399e0457a1464f8e1a", - "sha256:a47eef975d2b8b721775a0fa286f50eab535b9d56c70a6e62842134cf7841195", - "sha256:a8b5b9712783415695663bd463990e2f00c6750562e6ad1d28e072a611c5f2a6", - "sha256:a9015f5b8af1bb6837a3fcb0cdf3b874fe3385ff6274e8b7925d81ccaec3c5c9", - "sha256:aec510255ce690d240f7cb23d7114f6b351c733a74c279a84def763660a2c3bc", - "sha256:b00e7bcd71caa0282cbe3c90966f738e2db91e64092a877c3ff7f19a1628fdcb", - "sha256:b50aaac7d05c2c26dfd50c3321199f019ba76bb650e346a6ef3616306eed67b0", - "sha256:b7b6ea9e36d32582cda3465f54c4b454f62f23cb083ebc7a94e2ca6ef011c3a7", - "sha256:bb9333f58fc3a2296fb1d54576138d4cf5d496a2cc118422bd77835e6ae0b9cb", - "sha256:c1c13185b90bbd3f8b5963cd8ce7ad4ff441924c31e23c975cb150e27c2bf67a", - "sha256:c3b8bd3133cd50f6b637bb4322822c94c5ce4bf0d724ed5ae70afce62187c492", - "sha256:c5d97162c196ce54af6700949ddf9409e9833ef1003b4741c2b39ef46f1d9720", - "sha256:c815270206f983309915a6844fe994b2fa47e5d05c4c4cef267c3b30e34dbe42", - "sha256:cab2eba3830bf4f6d91e2d6718e0e1c14a2f5ad1af68a89d24ace0c6b17cced7", - "sha256:d1df34588123fcc88c872f5acb6f74ae59e9d182a2707097f9e28275ec26a12d", - "sha256:d6bdcd415ba87846fd317bee0774e412e8792832e7805938987e4ede1d13046d", - "sha256:db9a30ec064129d605d0f1aedc93e00894b9334ec74ba9c6bdd08147434b33eb", - "sha256:dbc183e7bef690c9abe5ea67b7b60fdbca81aa8da43468287dae7b5c046107d4", - "sha256:dca802c8db0720ce1c49cce1149ff7b06e91ba15fa84b1d59144fef1a1bc7ac2", - "sha256:dec6b307ce928e8e112a6bb9921a1cb00a0e14979bf28b98e084a4b8a742bd9b", - "sha256:df8bb0010fdd0a743b7542589223a2816bdde4d94bb5ad67884348fa2c1c67e8", - "sha256:e4094c7b464cf0a858e75cd14b03509e84789abf7b79f8537e6a72152109c76e", - "sha256:e4760a68cab57bfaa628938e9c2971137e05ce48e762a9cb53b76c9b569f1204", - "sha256:eb09b82377233b902d4c3fbeeb7ad731cdab579c6c6fda1f763cd779139e47c3", - "sha256:eb862356ee9391dc5a0b3cbc00f416b48c1b9a52d252d898e5b7696a5f9fe150", - "sha256:ef9528915df81b8f4c7612b19b8628214c65c9b7f74db2e34a646a0a2a0da2d4", - "sha256:f3157624b7558b914cb039fd1af735e5e8049a87c817cc215109ad1c8779df76", - "sha256:f3e0992f23bbb0be00a921eae5363329253c3b86287db27092461c887b791e5e", - "sha256:f9338cc05451f1942d0d8203ec2c346c830f8e86469903d5126c1f0a13a2bcbb", - "sha256:ffef8fd58a36fb5f1196919638f73dd3ae0db1a878982b27a9a5a176ede4ba91" - ], - "markers": "platform_python_implementation != 'PyPy'", - "version": "==1.17.0" + "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", + "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", + "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1", + "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", + "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", + "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", + "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", + "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36", + "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", + "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", + "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc", + "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", + "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", + "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", + "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", + "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", + "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", + "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", + "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", + "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b", + "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", + "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", + "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c", + "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", + "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", + "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", + "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8", + "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1", + "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", + "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", + "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", + "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", + "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", + "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", + "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", + "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", + "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", + "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", + "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", + "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16", + "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", + "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", + "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", + "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964", + "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", + "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", + "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", + "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", + "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662", + "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", + "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", + "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", + "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", + "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", + "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", + "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", + "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", + "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9", + "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7", + "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", + "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", + "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", + "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", + "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", + "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", + "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", + "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b" + ], + "markers": "python_version >= '3.8'", + "version": "==1.17.1" }, "cfgv": { "hashes": [ @@ -317,36 +317,36 @@ }, "cryptography": { "hashes": [ - "sha256:0663585d02f76929792470451a5ba64424acc3cd5227b03921dab0e2f27b1709", - "sha256:08a24a7070b2b6804c1940ff0f910ff728932a9d0e80e7814234269f9d46d069", - "sha256:232ce02943a579095a339ac4b390fbbe97f5b5d5d107f8a08260ea2768be8cc2", - "sha256:2905ccf93a8a2a416f3ec01b1a7911c3fe4073ef35640e7ee5296754e30b762b", - "sha256:299d3da8e00b7e2b54bb02ef58d73cd5f55fb31f33ebbf33bd00d9aa6807df7e", - "sha256:2c6d112bf61c5ef44042c253e4859b3cbbb50df2f78fa8fae6747a7814484a70", - "sha256:31e44a986ceccec3d0498e16f3d27b2ee5fdf69ce2ab89b52eaad1d2f33d8778", - "sha256:3d9a1eca329405219b605fac09ecfc09ac09e595d6def650a437523fcd08dd22", - "sha256:3dcdedae5c7710b9f97ac6bba7e1052b95c7083c9d0e9df96e02a1932e777895", - "sha256:47ca71115e545954e6c1d207dd13461ab81f4eccfcb1345eac874828b5e3eaaf", - "sha256:4a997df8c1c2aae1e1e5ac49c2e4f610ad037fc5a3aadc7b64e39dea42249431", - "sha256:51956cf8730665e2bdf8ddb8da0056f699c1a5715648c1b0144670c1ba00b48f", - "sha256:5bcb8a5620008a8034d39bce21dc3e23735dfdb6a33a06974739bfa04f853947", - "sha256:64c3f16e2a4fc51c0d06af28441881f98c5d91009b8caaff40cf3548089e9c74", - "sha256:6e2b11c55d260d03a8cf29ac9b5e0608d35f08077d8c087be96287f43af3ccdc", - "sha256:7b3f5fe74a5ca32d4d0f302ffe6680fcc5c28f8ef0dc0ae8f40c0f3a1b4fca66", - "sha256:844b6d608374e7d08f4f6e6f9f7b951f9256db41421917dfb2d003dde4cd6b66", - "sha256:9a8d6802e0825767476f62aafed40532bd435e8a5f7d23bd8b4f5fd04cc80ecf", - "sha256:aae4d918f6b180a8ab8bf6511a419473d107df4dbb4225c7b48c5c9602c38c7f", - "sha256:ac1955ce000cb29ab40def14fd1bbfa7af2017cca696ee696925615cafd0dce5", - "sha256:b88075ada2d51aa9f18283532c9f60e72170041bba88d7f37e49cbb10275299e", - "sha256:cb013933d4c127349b3948aa8aaf2f12c0353ad0eccd715ca789c8a0f671646f", - "sha256:cc70b4b581f28d0a254d006f26949245e3657d40d8857066c2ae22a61222ef55", - "sha256:e9c5266c432a1e23738d178e51c2c7a5e2ddf790f248be939448c0ba2021f9d1", - "sha256:ea9e57f8ea880eeea38ab5abf9fbe39f923544d7884228ec67d666abd60f5a47", - "sha256:ee0c405832ade84d4de74b9029bedb7b31200600fa524d218fc29bfa371e97f5", - "sha256:fdcb265de28585de5b859ae13e3846a8e805268a823a12a4da2597f1f5afc9f0" + "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362", + "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4", + "sha256:1ec0bcf7e17c0c5669d881b1cd38c4972fade441b27bda1051665faaa89bdcaa", + "sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83", + "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff", + "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805", + "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6", + "sha256:4a02ded6cd4f0a5562a8887df8b3bd14e822a90f97ac5e544c162899bc467664", + "sha256:53a583b6637ab4c4e3591a15bc9db855b8d9dee9a669b550f311480acab6eb08", + "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e", + "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18", + "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f", + "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73", + "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5", + "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984", + "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd", + "sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3", + "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e", + "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405", + "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2", + "sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c", + "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995", + "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73", + "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16", + "sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7", + "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd", + "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7" ], "markers": "python_version >= '3.7'", - "version": "==43.0.0" + "version": "==43.0.3" }, "distlib": { "hashes": [ @@ -1118,6 +1118,48 @@ "markers": "python_version >= '3.7'", "version": "==0.11.1" }, + "types-cffi": { + "hashes": [ + "sha256:a363e5ea54a4eb6a4a105d800685fde596bc318089b025b27dee09849fe41ff0", + "sha256:b8b20d23a2b89cfed5f8c5bc53b0cb8677c3aac6d970dbc771e28b9c698f5dee" + ], + "markers": "python_version >= '3.8'", + "version": "==1.16.0.20240331" + }, + "types-pyopenssl": { + "hashes": [ + "sha256:47913b4678a01d879f503a12044468221ed8576263c1540dcb0484ca21b08c39", + "sha256:6a7a5d2ec042537934cfb4c9d4deb0e16c4c6250b09358df1f083682fe6fda54" + ], + "markers": "python_version >= '3.8'", + "version": "==24.1.0.20240722" + }, + "types-pytz": { + "hashes": [ + "sha256:3e22df1336c0c6ad1d29163c8fda82736909eb977281cb823c57f8bae07118b7", + "sha256:575dc38f385a922a212bac00a7d6d2e16e141132a3c955078f4a4fd13ed6cb44" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==2024.2.0.20241003" + }, + "types-redis": { + "hashes": [ + "sha256:5f17d2b3f9091ab75384153bfa276619ffa1cf6a38da60e10d5e6749cc5b902e", + "sha256:ef5da68cb827e5f606c8f9c0b49eeee4c2669d6d97122f301d3a55dc6a63f6ed" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==4.6.0.20241004" + }, + "types-setuptools": { + "hashes": [ + "sha256:78cb5fef4a6056d2f37114d27da90f4655a306e4e38042d7034a8a880bc3f5dd", + "sha256:f9e1ebd17a56f606e16395c4ee4efa1cdc394b9a2a0ee898a624058b4b62ef8f" + ], + "markers": "python_version >= '3.8'", + "version": "==75.3.0.20241112" + }, "typing-extensions": { "hashes": [ "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", @@ -1265,89 +1307,78 @@ }, "coverage": { "hashes": [ - "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca", - "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d", - "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6", - "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989", - "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c", - "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b", - "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223", - "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f", - "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56", - "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3", - "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8", - "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb", - "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388", - "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0", - "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a", - "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8", - "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f", - "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a", - "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962", - "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8", - "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391", - "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc", - "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2", - "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155", - "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb", - "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0", - "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c", - "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a", - "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004", - "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060", - "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232", - "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93", - "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129", - "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163", - "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de", - "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6", - "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23", - "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569", - "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d", - "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778", - "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d", - "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36", - "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a", - "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6", - "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34", - "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704", - "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106", - "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9", - "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862", - "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b", - "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255", - "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16", - "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3", - "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133", - "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb", - "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657", - "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d", - "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca", - "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36", - "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c", - "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e", - "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff", - "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7", - "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5", - "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02", - "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c", - "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df", - "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3", - "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a", - "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959", - "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234", - "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc" + "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" ], "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==7.6.1" + "markers": "python_version >= '3.9'", + "version": "==7.6.4" }, "django": { "hashes": [ "sha256:56ab63a105e8bb06ee67381d7b65fe6774f057e41a8bab06c8020c8882d8ecd4", "sha256:b5bb1d11b2518a5f91372a282f24662f58f66749666b0a286ab057029f728080" ], - "index": "pypi", "markers": "python_version >= '3.10'", "version": "==5.0.2" }, @@ -1571,11 +1602,11 @@ }, "packaging": { "hashes": [ - "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", - "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124" + "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", + "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f" ], "markers": "python_version >= '3.8'", - "version": "==24.1" + "version": "==24.2" }, "pathspec": { "hashes": [ @@ -1587,11 +1618,11 @@ }, "platformdirs": { "hashes": [ - "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee", - "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3" + "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", + "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb" ], "markers": "python_version >= '3.8'", - "version": "==4.2.2" + "version": "==4.3.6" }, "pluggy": { "hashes": [ @@ -1619,12 +1650,12 @@ }, "pytest": { "hashes": [ - "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5", - "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce" + "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", + "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==8.3.2" + "version": "==8.3.3" }, "pytoolconfig": { "extras": [ diff --git a/backend/dining/api_wrapper.py b/backend/dining/api_wrapper.py index 3c55cf18..7ba539d2 100644 --- a/backend/dining/api_wrapper.py +++ b/backend/dining/api_wrapper.py @@ -65,6 +65,8 @@ def get_venues(self) -> list[dict[str, Any]]: for key, value in venues.items(): # Cleaning up json response venue = Venue.objects.filter(venue_id=key).first() + if venue is None: + continue value["name"] = venue.name value["image"] = venue.image_url if venue else None diff --git a/backend/dining/migrations/0001_initial.py b/backend/dining/migrations/0001_initial.py index 51b3bc0f..5cc33ee2 100644 --- a/backend/dining/migrations/0001_initial.py +++ b/backend/dining/migrations/0001_initial.py @@ -9,9 +9,7 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - ("user", "0002_profile_laundry_preferences"), - ] + dependencies = [("user", "0002_profile_laundry_preferences")] operations = [ migrations.CreateModel( diff --git a/backend/dining/migrations/0002_diningitem_diningstation_diningmenu.py b/backend/dining/migrations/0002_diningitem_diningstation_diningmenu.py index 0caaa2be..51874ad9 100644 --- a/backend/dining/migrations/0002_diningitem_diningstation_diningmenu.py +++ b/backend/dining/migrations/0002_diningitem_diningstation_diningmenu.py @@ -7,9 +7,7 @@ class Migration(migrations.Migration): - dependencies = [ - ("dining", "0001_initial"), - ] + dependencies = [("dining", "0001_initial")] operations = [ migrations.CreateModel( diff --git a/backend/dining/migrations/0003_venue_name.py b/backend/dining/migrations/0003_venue_name.py index 40dada7b..9eddb770 100644 --- a/backend/dining/migrations/0003_venue_name.py +++ b/backend/dining/migrations/0003_venue_name.py @@ -5,14 +5,10 @@ class Migration(migrations.Migration): - dependencies = [ - ("dining", "0002_diningitem_diningstation_diningmenu"), - ] + dependencies = [("dining", "0002_diningitem_diningstation_diningmenu")] operations = [ migrations.AddField( - model_name="venue", - name="name", - field=models.CharField(max_length=255, null=True), - ), + model_name="venue", name="name", field=models.CharField(max_length=255, null=True) + ) ] diff --git a/backend/dining/migrations/0004_remove_diningtransaction_profile_and_more.py b/backend/dining/migrations/0004_remove_diningtransaction_profile_and_more.py index a0f90009..db86346a 100644 --- a/backend/dining/migrations/0004_remove_diningtransaction_profile_and_more.py +++ b/backend/dining/migrations/0004_remove_diningtransaction_profile_and_more.py @@ -5,29 +5,16 @@ class Migration(migrations.Migration): - dependencies = [ - ("dining", "0003_venue_name"), - ] + dependencies = [("dining", "0003_venue_name")] operations = [ - migrations.RemoveField( - model_name="diningtransaction", - name="profile", - ), + migrations.RemoveField(model_name="diningtransaction", name="profile"), migrations.AlterField( - model_name="diningitem", - name="description", - field=models.CharField(max_length=1000), + model_name="diningitem", name="description", field=models.CharField(max_length=1000) ), migrations.AlterField( - model_name="diningitem", - name="ingredients", - field=models.CharField(max_length=1000), - ), - migrations.DeleteModel( - name="DiningBalance", - ), - migrations.DeleteModel( - name="DiningTransaction", + model_name="diningitem", name="ingredients", field=models.CharField(max_length=1000) ), + migrations.DeleteModel(name="DiningBalance"), + migrations.DeleteModel(name="DiningTransaction"), ] diff --git a/backend/dining/migrations/0005_diningitem_allergens_diningitem_nutrition_info_and_more.py b/backend/dining/migrations/0005_diningitem_allergens_diningitem_nutrition_info_and_more.py index 401ece69..f98c3631 100644 --- a/backend/dining/migrations/0005_diningitem_allergens_diningitem_nutrition_info_and_more.py +++ b/backend/dining/migrations/0005_diningitem_allergens_diningitem_nutrition_info_and_more.py @@ -6,9 +6,7 @@ class Migration(migrations.Migration): - dependencies = [ - ("dining", "0004_remove_diningtransaction_profile_and_more"), - ] + dependencies = [("dining", "0004_remove_diningtransaction_profile_and_more")] operations = [ migrations.AddField( diff --git a/backend/dining/migrations/0006_remove_diningmenu_stations_and_more.py b/backend/dining/migrations/0006_remove_diningmenu_stations_and_more.py index 35e63881..a6eec85d 100644 --- a/backend/dining/migrations/0006_remove_diningmenu_stations_and_more.py +++ b/backend/dining/migrations/0006_remove_diningmenu_stations_and_more.py @@ -6,15 +6,10 @@ class Migration(migrations.Migration): - dependencies = [ - ("dining", "0005_diningitem_allergens_diningitem_nutrition_info_and_more"), - ] + dependencies = [("dining", "0005_diningitem_allergens_diningitem_nutrition_info_and_more")] operations = [ - migrations.RemoveField( - model_name="diningmenu", - name="stations", - ), + migrations.RemoveField(model_name="diningmenu", name="stations"), migrations.AlterField( model_name="diningitem", name="allergens", diff --git a/backend/dining/models.py b/backend/dining/models.py index dbb5064a..ae5b7b96 100644 --- a/backend/dining/models.py +++ b/backend/dining/models.py @@ -3,37 +3,43 @@ class Venue(models.Model): - venue_id = models.IntegerField(primary_key=True) - name = models.CharField(max_length=255, null=True) - image_url = models.URLField() + venue_id: models.IntegerField = models.IntegerField(primary_key=True) + name: models.CharField = models.CharField(max_length=255, null=True) + image_url: models.URLField = models.URLField() - def __str__(self): + def __str__(self) -> str: return f"{self.name}-{str(self.venue_id)}" class DiningItem(models.Model): - item_id = models.IntegerField(primary_key=True) - name = models.CharField(max_length=255) - description = models.CharField(max_length=1000, blank=True) - ingredients = models.CharField(max_length=1000, blank=True) # comma separated list - allergens = models.CharField(max_length=1000, blank=True) # comma separated list - nutrition_info = models.CharField(max_length=1000, blank=True) # json string. + item_id: models.IntegerField = models.IntegerField(primary_key=True) + name: models.CharField = models.CharField(max_length=255) + description: models.CharField = models.CharField(max_length=1000, blank=True) + ingredients: models.CharField = models.CharField( + max_length=1000, blank=True + ) # comma separated list + allergens: models.CharField = models.CharField( + max_length=1000, blank=True + ) # comma separated list + nutrition_info: models.CharField = models.CharField(max_length=1000, blank=True) # json string. # Technically, postgres supports json fields but that involves local postgres # instead of sqlite AND we don't need to query on this field - def __str__(self): + def __str__(self) -> str: return f"{self.name}" class DiningStation(models.Model): - name = models.CharField(max_length=255) - items = models.ManyToManyField(DiningItem) - menu = models.ForeignKey("DiningMenu", on_delete=models.CASCADE, related_name="stations") + name: models.CharField = models.CharField(max_length=255) + items: models.ManyToManyField = models.ManyToManyField(DiningItem) + menu: models.ForeignKey = models.ForeignKey( + "DiningMenu", on_delete=models.CASCADE, related_name="stations" + ) class DiningMenu(models.Model): - venue = models.ForeignKey(Venue, on_delete=models.CASCADE) - date = models.DateField(default=timezone.now) - start_time = models.DateTimeField() - end_time = models.DateTimeField() - service = models.CharField(max_length=255) + venue: models.ForeignKey = models.ForeignKey(Venue, on_delete=models.CASCADE) + date: models.DateField = models.DateField(default=timezone.now) + start_time: models.DateTimeField = models.DateTimeField() + end_time: models.DateTimeField = models.DateTimeField() + service: models.CharField = models.CharField(max_length=255) diff --git a/backend/dining/views.py b/backend/dining/views.py index 8b1d1ff5..35c5240c 100644 --- a/backend/dining/views.py +++ b/backend/dining/views.py @@ -1,7 +1,7 @@ import datetime from django.core.cache import cache -from django.db.models import Count, QuerySet +from django.db.models import Count, Manager, QuerySet from django.shortcuts import get_object_or_404 from django.utils import timezone from django.utils.timezone import make_aware @@ -15,6 +15,7 @@ from dining.models import DiningMenu, Venue from dining.serializers import DiningMenuSerializer from utils.cache import Cache +from utils.types import get_user d = DiningAPIWrapper() @@ -40,7 +41,7 @@ class Menus(generics.ListAPIView): serializer_class = DiningMenuSerializer - def get_queryset(self) -> QuerySet[DiningMenu]: + def get_queryset(self) -> QuerySet[DiningMenu, Manager[DiningMenu]]: # TODO: We only have data for the next week, so we should 404 # if date_param is out of bounds if date_param := self.kwargs.get("date"): @@ -63,18 +64,19 @@ class Preferences(APIView): key = "dining_preferences:{user_id}" def get(self, request: Request) -> Response: - key = self.key.format(user_id=request.user.id) + key = self.key.format(user_id=get_user(request).id) cached_preferences = cache.get(key) if cached_preferences is None: - preferences = request.user.profile.dining_preferences + preferences = get_user(request).profile.dining_preferences # aggregates venues and puts it in form {"venue_id": x, "count": x} cached_preferences = preferences.values("venue_id").annotate(count=Count("venue_id")) cache.set(key, cached_preferences, Cache.MONTH) return Response({"preferences": cached_preferences}) def post(self, request: Request) -> Response: - key = self.key.format(user_id=request.user.id) - profile = request.user.profile + user = get_user(request) + key = self.key.format(user_id=user.id) + profile = user.profile preferences = profile.dining_preferences venue_ids = set(request.data["venues"]) diff --git a/backend/gsr_booking/admin.py b/backend/gsr_booking/admin.py index 5c1586d0..494f2886 100644 --- a/backend/gsr_booking/admin.py +++ b/backend/gsr_booking/admin.py @@ -1,8 +1,11 @@ +from typing import Any, cast + from django.contrib import admin -from django.db.models import QuerySet -from rest_framework.request import Request +from django.db.models import Manager, QuerySet +from django.http import HttpRequest from gsr_booking.models import GSR, Group, GroupMembership, GSRBooking, Reservation +from utils.types import UserType class GroupMembershipInline(admin.TabularInline): @@ -12,12 +15,13 @@ class GroupMembershipInline(admin.TabularInline): readonly_fields = ["name"] def name(self, obj: GroupMembership) -> str: - return obj.user.get_full_name() + user = cast(UserType, obj.user) + return str(user.get_full_name()) - def get_fields(self, request, obj=None) -> list[str]: + def get_fields(self, request: HttpRequest, obj: Any = None) -> list[str]: fields = super().get_fields(request, obj) to_remove = ["user", "name"] - return ["name"] + [f for f in fields if f not in to_remove] + return ["name"] + [str(f) for f in fields if f not in to_remove] class GroupAdmin(admin.ModelAdmin): @@ -33,7 +37,7 @@ class GroupMembershipAdmin(admin.ModelAdmin): class GSRAdmin(admin.ModelAdmin): - def get_queryset(self, request: Request) -> QuerySet[GSR]: + def get_queryset(self, request: HttpRequest) -> QuerySet[GSR, Manager[GSR]]: return GSR.all_objects.all() list_display = ["name", "kind", "lid", "gid", "in_use"] diff --git a/backend/gsr_booking/api_wrapper.py b/backend/gsr_booking/api_wrapper.py index f4f2b1e1..2f639fdc 100644 --- a/backend/gsr_booking/api_wrapper.py +++ b/backend/gsr_booking/api_wrapper.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod from enum import Enum from random import randint -from typing import TYPE_CHECKING, Any +from typing import Any, Optional, TypedDict, cast import requests from bs4 import BeautifulSoup @@ -17,15 +17,9 @@ from gsr_booking.models import GSR, Group, GroupMembership, GSRBooking, Reservation from gsr_booking.serializers import GSRBookingSerializer, GSRSerializer from utils.errors import APIError +from utils.types import UserType -if TYPE_CHECKING: - from django.contrib.auth.models import AbstractUser - - UserType = AbstractUser -else: - UserType = Any - User = get_user_model() BASE_URL = "https://libcal.library.upenn.edu" @@ -46,23 +40,40 @@ class CreditType(Enum): ARB = "ARB" +class AvailabilityTime(TypedDict): + start_time: str + end_time: str + + +class RoomInfo(TypedDict): + room_name: str + id: int + availability: list[AvailabilityTime] + + +class AvailabilityResponse(TypedDict): + name: str + gid: int + rooms: list[RoomInfo] + + class AbstractBookingWrapper(ABC): @abstractmethod - def book_room(self, rid: int, start: str, end: str, user: "UserType") -> dict[str, Any]: + def book_room(self, rid: int, start: str, end: str, user: UserType) -> dict[str, Any]: raise NotImplementedError # pragma: no cover @abstractmethod - def cancel_room(self, booking_id: str, user: "UserType") -> dict[str, Any]: + def cancel_room(self, booking_id: str, user: UserType) -> dict[str, Any]: raise NotImplementedError # pragma: no cover @abstractmethod def get_availability( - self, lid: int, start: str | None, end: str | None, user: "UserType" - ) -> list[dict[str, Any]]: + self, lid: str | int, start: str | None, end: str | None, user: UserType + ) -> list[RoomInfo]: raise NotImplementedError # pragma: no cover @abstractmethod - def get_reservations(self, user: "UserType") -> list[dict[str, Any]]: + def get_reservations(self, user: UserType) -> list[dict[str, Any]]: raise NotImplementedError # pragma: no cover @@ -82,21 +93,16 @@ def request(self, *args: Any, **kwargs: Any) -> requests.Response: return response - def book_room(self, rid: int, start: str, end: str, user: "UserType") -> dict[str, Any]: + def book_room(self, rid: int, start: str, end: str, user: UserType) -> dict[str, Any]: """Books room if pennkey is valid""" - payload = { - "start": start, - "end": end, - "pennkey": user.username, - "room": rid, - } + payload = {"start": start, "end": end, "pennkey": user.username, "room": rid} url = f"{WHARTON_URL}{user.username}/student_reserve" response = self.request("POST", url, json=payload).json() if "error" in response: raise APIError("Wharton: " + response["error"]) return response - def cancel_room(self, booking_id: str, user: "UserType") -> dict[str, Any]: + def cancel_room(self, booking_id: str, user: UserType) -> dict[str, Any]: """Cancels reservation given booking id""" url = f"{WHARTON_URL}{user.username}/reservations/{booking_id}/cancel" response = self.request("DELETE", url).json() @@ -105,8 +111,8 @@ def cancel_room(self, booking_id: str, user: "UserType") -> dict[str, Any]: return response def get_availability( - self, lid: int, start: str | None, end: str | None, user: "UserType" - ) -> list[dict[str, Any]]: + self, lid: str | int, start: str | None, end: str | None, user: UserType + ) -> list[RoomInfo]: """Returns a list of rooms and their availabilities""" current_time = timezone.localtime() search_date = ( @@ -144,7 +150,7 @@ def get_availability( room["availability"] = valid_slots return rooms - def get_reservations(self, user: "UserType") -> list[dict[str, Any]]: + def get_reservations(self, user: UserType) -> list[dict[str, Any]]: url = f"{WHARTON_URL}{user.username}/reservations" bookings = self.request("GET", url).json()["bookings"] @@ -162,7 +168,7 @@ def get_reservations(self, user: "UserType") -> list[dict[str, Any]]: if datetime.datetime.strptime(booking["end"], "%Y-%m-%dT%H:%M:%S%z") >= now ] - def is_wharton(self, user: "UserType") -> bool | None: + def is_wharton(self, user: UserType) -> bool | None: url = f"{WHARTON_URL}{user.username}/privileges" try: response = self.request("GET", url) @@ -175,7 +181,7 @@ def is_wharton(self, user: "UserType") -> bool | None: class LibCalBookingWrapper(AbstractBookingWrapper): - def __init__(self): + def __init__(self) -> None: self.token = None self.expiration = timezone.localtime() @@ -213,7 +219,7 @@ def request(self, *args: Any, **kwargs: Any) -> requests.Response: except (ConnectTimeout, ReadTimeout, ConnectionError): raise APIError("LibCal: Connection timeout") - def book_room(self, rid: int, start: str, end: str, user: "UserType") -> dict[str, Any]: + def book_room(self, rid: int, start: str, end: str, user: UserType) -> dict[str, Any]: """ Books room if pennkey is valid @@ -258,10 +264,10 @@ def book_room(self, rid: int, start: str, end: str, user: "UserType") -> dict[st raise APIError("LibCal: " + res_json["error"]) return res_json - def get_reservations(self, user: "UserType") -> list[dict[str, Any]]: - pass + def get_reservations(self, user: UserType) -> list[dict[str, Any]]: + return [] - def cancel_room(self, booking_id: str, user: "UserType") -> dict[str, Any]: + def cancel_room(self, booking_id: str, user: UserType) -> dict[str, Any]: """Cancels room""" response = self.request("POST", f"{API_URL}/1.1/space/cancel/{booking_id}").json() if "error" in response[0]: @@ -269,8 +275,8 @@ def cancel_room(self, booking_id: str, user: "UserType") -> dict[str, Any]: return response def get_availability( - self, gid: int, start: str | None, end: str | None, user: "UserType" - ) -> list[dict[str, Any]]: + self, gid: str | int, start: str | None, end: str | None, user: UserType + ) -> list[RoomInfo]: """Returns a list of rooms and their availabilities""" # adjusts url based on start and end times @@ -295,7 +301,7 @@ def get_availability( if response.status_code != 200: raise APIError(f"GSR Reserve: Error {response.status_code} when reserving data") - rooms = [ + rooms: list[dict[str, Any]] = [ {"room_name": room["name"], "id": room["id"], "availability": room["availability"]} for room in response.json() if room["id"] not in ROOM_BLACKLIST @@ -313,7 +319,7 @@ def get_availability( or datetime.datetime.strptime(time["from"][:-6], "%Y-%m-%dT%H:%M:%S") >= start_datetime ] - return rooms + return cast(list[RoomInfo], rooms) def get_affiliation(self, email: str) -> str: """Gets school from email""" @@ -328,13 +334,15 @@ def get_affiliation(self, email: str) -> str: class BookingHandler: - def __init__(self, WBW=None, LBW=None): + def __init__( + self, + WBW: Optional[AbstractBookingWrapper] = None, + LBW: Optional[AbstractBookingWrapper] = None, + ) -> None: self.WBW = WBW or WhartonBookingWrapper() self.LBW = LBW or LibCalBookingWrapper() - def format_members( - self, members: QuerySet[GroupMembership] - ) -> list[tuple["UserType", datetime.timedelta]]: + def format_members(self, members: QuerySet) -> list[tuple[UserType, datetime.timedelta]]: PREFIX = "user__" return [ ( @@ -348,7 +356,7 @@ def format_members( def get_wharton_members( self, group: Group, gsr_id: int - ) -> list[tuple["UserType", datetime.timedelta]]: + ) -> list[tuple[UserType, datetime.timedelta]]: now = timezone.localtime() ninty_min = datetime.timedelta(minutes=90) zero_min = datetime.timedelta(minutes=0) @@ -359,15 +367,18 @@ def get_wharton_members( .values("user") .annotate( credits=ninty_min - - Coalesce( - Sum( - F("user__gsrbooking__end") - F("user__gsrbooking__start"), - filter=Q(user__gsrbooking__gsr__gid=gsr_id) - & Q(user__gsrbooking__is_cancelled=False) - & Q(user__gsrbooking__end__gte=now), + - cast( + datetime.timedelta, + Coalesce( + Sum( + F("user__gsrbooking__end") - F("user__gsrbooking__start"), + filter=Q(user__gsrbooking__gsr__gid=gsr_id) + & Q(user__gsrbooking__is_cancelled=False) + & Q(user__gsrbooking__end__gte=now), + ), + zero_min, ), - zero_min, - ) + ), ) .filter(Q(credits__gt=zero_min)) .values("user__id", "user__username", "credits") @@ -375,7 +386,7 @@ def get_wharton_members( ) return self.format_members(ret) - def get_libcal_members(self, group: Group) -> list[tuple["UserType", datetime.timedelta]]: + def get_libcal_members(self, group: Group) -> list[tuple[UserType, datetime.timedelta]]: day_start = timezone.localtime().replace(hour=0, minute=0, second=0, microsecond=0) day_end = day_start + datetime.timedelta(days=1) two_hours = datetime.timedelta(hours=2) @@ -387,15 +398,18 @@ def get_libcal_members(self, group: Group) -> list[tuple["UserType", datetime.ti .values("user") .annotate( credits=two_hours - - Coalesce( - Sum( - F("user__gsrbooking__end") - F("user__gsrbooking__start"), - filter=Q(user__gsrbooking__gsr__kind=GSR.KIND_LIBCAL) - & Q(user__gsrbooking__is_cancelled=False) - & Q(user__gsrbooking__start__gte=day_start) - & Q(user__gsrbooking__end__lte=day_end), + - cast( + datetime.timedelta, + Coalesce( + Sum( + F("user__gsrbooking__end") - F("user__gsrbooking__start"), + filter=Q(user__gsrbooking__gsr__kind=GSR.KIND_LIBCAL) + & Q(user__gsrbooking__is_cancelled=False) + & Q(user__gsrbooking__start__gte=day_start) + & Q(user__gsrbooking__end__lte=day_end), + ), + zero_min, ), - zero_min, ) ) .filter(Q(credits__gt=zero_min)) @@ -418,14 +432,14 @@ def book_room( room_name: str, start: str, end: str, - user: "UserType", - group: Group | None = None, + user: UserType, + group: Optional[Group] = None, ) -> Reservation: # NOTE when booking with a group, we are only querying our db for existing bookings, # so users in a group who book through wharton may screw up the query gsr = get_object_or_404(GSR, gid=gid) - start = datetime.datetime.strptime(start, "%Y-%m-%dT%H:%M:%S%z") - end = datetime.datetime.strptime(end, "%Y-%m-%dT%H:%M:%S%z") + start_dt = datetime.datetime.strptime(start, "%Y-%m-%dT%H:%M:%S%z") + end_dt = datetime.datetime.strptime(end, "%Y-%m-%dT%H:%M:%S%z") book_func = self.WBW.book_room if gsr.kind == GSR.KIND_WHARTON else self.LBW.book_room members = ( @@ -442,15 +456,17 @@ def book_room( [time_available for _, time_available in members], datetime.timedelta(minutes=0) ) - if (end - start) >= total_time_available: + if (end_dt - start_dt) >= total_time_available: raise APIError("Error: Not enough credits") - reservation = Reservation.objects.create(start=start, end=end, creator=user, group=group) + reservation = Reservation.objects.create( + start=start_dt, end=end_dt, creator=user, group=group + ) - curr_start = start + curr_start = start_dt try: for curr_user, time_available in members: - curr_end = curr_start + min(time_available, end - curr_start) + curr_end = curr_start + min(time_available, end_dt - curr_start) booking_id = book_func( rid, @@ -470,17 +486,17 @@ def book_room( booking.reservation = reservation booking.save() - if (curr_start := curr_end) >= end: + if (curr_start := curr_end) >= end_dt: break except APIError as e: raise APIError( - f"{str(e)}. Was only able to book {start.strftime('%H:%M')}" + f"{str(e)}. Was only able to book {start_dt.strftime('%H:%M')}" f" - {curr_start.strftime('%H:%M')}" ) return reservation - def cancel_room(self, booking_id: str, user: "UserType") -> None: + def cancel_room(self, booking_id: str, user: UserType) -> None | APIError: if ( gsr_booking := GSRBooking.objects.filter(booking_id=booking_id) .prefetch_related(Prefetch("reservation__gsrbooking_set"), Prefetch("gsr")) @@ -504,20 +520,20 @@ def cancel_room(self, booking_id: str, user: "UserType") -> None: for service in [self.WBW, self.LBW]: try: service.cancel_room(booking_id, user) - return + return None except APIError: - pass - raise APIError("Error: Unknown booking id") + raise APIError("Error: Unknown booking id") + return None def get_availability( self, - lid: int, - gid: int, + lid: str | int, + gid: str | int, start: str | None, end: str | None, - user: "UserType", - group: Group | None = None, - ) -> list[dict[str, Any]]: + user: UserType, + group: Optional[Group] = None, + ) -> AvailabilityResponse: gsr = get_object_or_404(GSR, gid=gid) # select a random user from the group if booking wharton gsr @@ -535,7 +551,7 @@ def get_availability( return {"name": gsr.name, "gid": gsr.gid, "rooms": rooms} def get_reservations( - self, user: "UserType", group: Group | None = None + self, user: UserType, group: Optional[Group] = None ) -> list[dict[str, Any]]: q = Q(user=user) | Q(reservation__creator=user) if group else Q(user=user) bookings = GSRBooking.objects.filter( @@ -543,16 +559,16 @@ def get_reservations( ).prefetch_related(Prefetch("reservation")) if group: - ret = [] + ret: list[dict[str, Any]] = [] for booking in bookings: - data = GSRBookingSerializer(booking).data + data = cast(dict[str, Any], GSRBookingSerializer(booking).data) if booking.reservation.creator == user: data["room_name"] = f"[Me] {data['room_name']}" else: data["room_name"] = f"[{group.name}] {data['room_name']}" ret.append(data) else: - ret = GSRBookingSerializer(bookings, many=True).data + ret = cast(list[dict[str, Any]], GSRBookingSerializer(bookings, many=True).data) # deal with bookings made directly through wharton (not us) try: diff --git a/backend/gsr_booking/management/commands/change_group.py b/backend/gsr_booking/management/commands/change_group.py index b2832087..28eeb7f2 100644 --- a/backend/gsr_booking/management/commands/change_group.py +++ b/backend/gsr_booking/management/commands/change_group.py @@ -1,3 +1,4 @@ +from argparse import ArgumentParser from typing import Any from django.contrib.auth import get_user_model @@ -15,7 +16,7 @@ class Command(BaseCommand): Adds/remove users to a group. """ - def add_arguments(self, parser) -> None: + def add_arguments(self, parser: ArgumentParser) -> None: parser.add_argument("usernames", type=str, help="list of pennkeys") parser.add_argument("group", type=str, help="group name") parser.add_argument("mode", type=str, help="mode of operation (add/remove/reset)") diff --git a/backend/gsr_booking/management/commands/get_reservations.py b/backend/gsr_booking/management/commands/get_reservations.py index 609478da..9e644b0e 100644 --- a/backend/gsr_booking/management/commands/get_reservations.py +++ b/backend/gsr_booking/management/commands/get_reservations.py @@ -1,3 +1,4 @@ +from argparse import ArgumentParser from datetime import datetime from typing import Any @@ -28,7 +29,7 @@ class Command(BaseCommand): Note: --start/--end and --current are mutually exclusive """ - def add_arguments(self, parser) -> None: + def add_arguments(self, parser: ArgumentParser) -> None: # optional flags parser.add_argument("--group", type=str, default=None) parser.add_argument("--start", type=str, default=None) diff --git a/backend/gsr_booking/management/commands/individual_usage.py b/backend/gsr_booking/management/commands/individual_usage.py index 6b27fbaf..80dbf32c 100644 --- a/backend/gsr_booking/management/commands/individual_usage.py +++ b/backend/gsr_booking/management/commands/individual_usage.py @@ -1,3 +1,4 @@ +from argparse import ArgumentParser from typing import Any from django.core.management.base import BaseCommand @@ -9,7 +10,7 @@ class Command(BaseCommand): help = "Provides usage stats for a given user." - def add_arguments(self, parser) -> None: + def add_arguments(self, parser: ArgumentParser) -> None: parser.add_argument("pennkey", type=str, help="Pennkey of user to check") def handle(self, *args: Any, **kwargs: Any) -> None: diff --git a/backend/gsr_booking/migrations/0001_initial.py b/backend/gsr_booking/migrations/0001_initial.py index 7ff4fead..d3b8d594 100644 --- a/backend/gsr_booking/migrations/0001_initial.py +++ b/backend/gsr_booking/migrations/0001_initial.py @@ -18,10 +18,7 @@ class Migration(migrations.Migration): ( "id", models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" ), ), ("name", models.CharField(max_length=255)), @@ -36,22 +33,15 @@ class Migration(migrations.Migration): ( "id", models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" ), ), ("accepted", models.BooleanField(default=False)), - ( - "type", - models.CharField(choices=[("A", "Admin"), ("M", "M")], max_length=10), - ), + ("type", models.CharField(choices=[("A", "Admin"), ("M", "M")], max_length=10)), ( "group", models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="gsr_booking.Group", + on_delete=django.db.models.deletion.CASCADE, to="gsr_booking.Group" ), ), ( diff --git a/backend/gsr_booking/migrations/0001_squashed_0011_merge_20200418_2009.py b/backend/gsr_booking/migrations/0001_squashed_0011_merge_20200418_2009.py index a6f36cba..c3fa6e9c 100644 --- a/backend/gsr_booking/migrations/0001_squashed_0011_merge_20200418_2009.py +++ b/backend/gsr_booking/migrations/0001_squashed_0011_merge_20200418_2009.py @@ -34,10 +34,7 @@ class Migration(migrations.Migration): ( "id", models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" ), ), ("name", models.CharField(max_length=255)), @@ -52,22 +49,15 @@ class Migration(migrations.Migration): ( "id", models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" ), ), ("accepted", models.BooleanField(default=False)), - ( - "type", - models.CharField(choices=[("A", "Admin"), ("M", "M")], max_length=10), - ), + ("type", models.CharField(choices=[("A", "Admin"), ("M", "M")], max_length=10)), ( "group", models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="gsr_booking.Group", + on_delete=django.db.models.deletion.CASCADE, to="gsr_booking.Group" ), ), ( @@ -82,10 +72,7 @@ class Migration(migrations.Migration): ), ("notifications", models.BooleanField(default=True)), ("pennkey_allow", models.BooleanField(default=False)), - ( - "username", - models.CharField(blank=True, default=None, max_length=127, null=True), - ), + ("username", models.CharField(blank=True, default=None, max_length=127, null=True)), ], options={"verbose_name": "Group Membership"}, ), @@ -114,10 +101,7 @@ class Migration(migrations.Migration): ( "id", models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" ), ), ("full_name", models.CharField(db_index=True, max_length=255)), @@ -125,8 +109,7 @@ class Migration(migrations.Migration): ( "user", models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL ), ), ], @@ -137,10 +120,7 @@ class Migration(migrations.Migration): ( "id", models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" ), ), ( @@ -156,17 +136,13 @@ class Migration(migrations.Migration): ( "email", models.CharField( - max_length=255, - null=True, - unique=True, - verbose_name="school email", + max_length=255, null=True, unique=True, verbose_name="school email" ), ), ( "user", models.OneToOneField( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL ), ), ( diff --git a/backend/gsr_booking/migrations/0002_auto_20210129_1527.py b/backend/gsr_booking/migrations/0002_auto_20210129_1527.py index 0411d13d..68848799 100644 --- a/backend/gsr_booking/migrations/0002_auto_20210129_1527.py +++ b/backend/gsr_booking/migrations/0002_auto_20210129_1527.py @@ -13,14 +13,8 @@ class Migration(migrations.Migration): ] operations = [ - migrations.RemoveField( - model_name="gsrbookingcredentials", - name="email", - ), - migrations.RemoveField( - model_name="gsrbookingcredentials", - name="id", - ), + migrations.RemoveField(model_name="gsrbookingcredentials", name="email"), + migrations.RemoveField(model_name="gsrbookingcredentials", name="id"), migrations.AlterField( model_name="gsrbookingcredentials", name="date_updated", diff --git a/backend/gsr_booking/migrations/0004_alter_gsr_lid.py b/backend/gsr_booking/migrations/0004_alter_gsr_lid.py index b9955207..84bf6c65 100644 --- a/backend/gsr_booking/migrations/0004_alter_gsr_lid.py +++ b/backend/gsr_booking/migrations/0004_alter_gsr_lid.py @@ -5,14 +5,8 @@ class Migration(migrations.Migration): - dependencies = [ - ("gsr_booking", "0003_gsr_gsrbooking"), - ] + dependencies = [("gsr_booking", "0003_gsr_gsrbooking")] operations = [ - migrations.AlterField( - model_name="gsr", - name="lid", - field=models.CharField(max_length=255), - ), + migrations.AlterField(model_name="gsr", name="lid", field=models.CharField(max_length=255)) ] diff --git a/backend/gsr_booking/migrations/0005_reservation.py b/backend/gsr_booking/migrations/0005_reservation.py index 14928fbc..b6b71649 100644 --- a/backend/gsr_booking/migrations/0005_reservation.py +++ b/backend/gsr_booking/migrations/0005_reservation.py @@ -44,5 +44,5 @@ class Migration(migrations.Migration): ), ), ], - ), + ) ] diff --git a/backend/gsr_booking/migrations/0005_usersearchindex.py b/backend/gsr_booking/migrations/0005_usersearchindex.py index 61345e3b..a64bc4c6 100644 --- a/backend/gsr_booking/migrations/0005_usersearchindex.py +++ b/backend/gsr_booking/migrations/0005_usersearchindex.py @@ -19,10 +19,7 @@ class Migration(migrations.Migration): ( "id", models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" ), ), ("full_name", models.CharField(db_index=True, max_length=255)), @@ -30,8 +27,7 @@ class Migration(migrations.Migration): ( "user", models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL ), ), ], diff --git a/backend/gsr_booking/migrations/0006_auto_20200207_2126.py b/backend/gsr_booking/migrations/0006_auto_20200207_2126.py index 378edc5b..69704727 100644 --- a/backend/gsr_booking/migrations/0006_auto_20200207_2126.py +++ b/backend/gsr_booking/migrations/0006_auto_20200207_2126.py @@ -6,9 +6,7 @@ class Migration(migrations.Migration): - dependencies = [ - ("gsr_booking", "0005_usersearchindex"), - ] + dependencies = [("gsr_booking", "0005_usersearchindex")] operations = [ migrations.AlterField( diff --git a/backend/gsr_booking/migrations/0006_auto_20211024_1231.py b/backend/gsr_booking/migrations/0006_auto_20211024_1231.py index 05297a76..6c636569 100644 --- a/backend/gsr_booking/migrations/0006_auto_20211024_1231.py +++ b/backend/gsr_booking/migrations/0006_auto_20211024_1231.py @@ -8,21 +8,13 @@ def create_single_user_group(apps, schema_editor): Group = apps.get_model("gsr_booking", "Group") GroupMembership = apps.get_model("gsr_booking", "GroupMembership") for user in User.objects.all(): - group, created = Group.objects.get_or_create( - owner=user, - name="Me", - color="#14f7d1", - ) + group, created = Group.objects.get_or_create(owner=user, name="Me", color="#14f7d1") if created: GroupMembership.objects.get_or_create(group=group, user=user, type="A", accepted=True) class Migration(migrations.Migration): - dependencies = [ - ("gsr_booking", "0005_reservation"), - ] + dependencies = [("gsr_booking", "0005_reservation")] - operations = [ - migrations.RunPython(create_single_user_group), - ] + operations = [migrations.RunPython(create_single_user_group)] diff --git a/backend/gsr_booking/migrations/0006_gsrbookingcredentials.py b/backend/gsr_booking/migrations/0006_gsrbookingcredentials.py index da854a7d..9214c372 100644 --- a/backend/gsr_booking/migrations/0006_gsrbookingcredentials.py +++ b/backend/gsr_booking/migrations/0006_gsrbookingcredentials.py @@ -19,10 +19,7 @@ class Migration(migrations.Migration): ( "id", models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" ), ), ( @@ -42,19 +39,15 @@ class Migration(migrations.Migration): ( "email", models.CharField( - max_length=255, - null=True, - unique=True, - verbose_name="school email", + max_length=255, null=True, unique=True, verbose_name="school email" ), ), ( "user", models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL ), ), ], - ), + ) ] diff --git a/backend/gsr_booking/migrations/0007_delete_gsrbookingcredentials.py b/backend/gsr_booking/migrations/0007_delete_gsrbookingcredentials.py index 4a089e57..af5a2a01 100644 --- a/backend/gsr_booking/migrations/0007_delete_gsrbookingcredentials.py +++ b/backend/gsr_booking/migrations/0007_delete_gsrbookingcredentials.py @@ -5,12 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ - ("gsr_booking", "0006_auto_20211024_1231"), - ] + dependencies = [("gsr_booking", "0006_auto_20211024_1231")] - operations = [ - migrations.DeleteModel( - name="GSRBookingCredentials", - ), - ] + operations = [migrations.DeleteModel(name="GSRBookingCredentials")] diff --git a/backend/gsr_booking/migrations/0008_auto_20200202_1218.py b/backend/gsr_booking/migrations/0008_auto_20200202_1218.py index 74069e66..239eed6d 100644 --- a/backend/gsr_booking/migrations/0008_auto_20200202_1218.py +++ b/backend/gsr_booking/migrations/0008_auto_20200202_1218.py @@ -19,5 +19,5 @@ class Migration(migrations.Migration): field=models.OneToOneField( on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL ), - ), + ) ] diff --git a/backend/gsr_booking/migrations/0008_auto_20211112_1657.py b/backend/gsr_booking/migrations/0008_auto_20211112_1657.py index bedd8c7d..5a9ea923 100644 --- a/backend/gsr_booking/migrations/0008_auto_20211112_1657.py +++ b/backend/gsr_booking/migrations/0008_auto_20211112_1657.py @@ -30,15 +30,10 @@ def create_reservation_for_booking(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ - ("gsr_booking", "0007_delete_gsrbookingcredentials"), - ] + dependencies = [("gsr_booking", "0007_delete_gsrbookingcredentials")] operations = [ - migrations.RemoveField( - model_name="reservation", - name="gsr", - ), + migrations.RemoveField(model_name="reservation", name="gsr"), migrations.AddField( model_name="gsrbooking", name="reservation", @@ -47,12 +42,8 @@ class Migration(migrations.Migration): ), ), migrations.AddField( - model_name="reservation", - name="is_cancelled", - field=models.BooleanField(default=False), - ), - migrations.DeleteModel( - name="UserSearchIndex", + model_name="reservation", name="is_cancelled", field=models.BooleanField(default=False) ), + migrations.DeleteModel(name="UserSearchIndex"), migrations.RunPython(create_reservation_for_booking), ] diff --git a/backend/gsr_booking/migrations/0009_auto_20200202_1232.py b/backend/gsr_booking/migrations/0009_auto_20200202_1232.py index d45a3235..d6c9519e 100644 --- a/backend/gsr_booking/migrations/0009_auto_20200202_1232.py +++ b/backend/gsr_booking/migrations/0009_auto_20200202_1232.py @@ -5,14 +5,12 @@ class Migration(migrations.Migration): - dependencies = [ - ("gsr_booking", "0008_auto_20200202_1218"), - ] + dependencies = [("gsr_booking", "0008_auto_20200202_1218")] operations = [ migrations.AlterField( model_name="gsrbookingcredentials", name="expiration_date", field=models.DateTimeField(null=True, verbose_name="session ID expiration date"), - ), + ) ] diff --git a/backend/gsr_booking/migrations/0009_remove_groupmembership_username_and_more.py b/backend/gsr_booking/migrations/0009_remove_groupmembership_username_and_more.py index 9a9e70c0..f7f6d857 100644 --- a/backend/gsr_booking/migrations/0009_remove_groupmembership_username_and_more.py +++ b/backend/gsr_booking/migrations/0009_remove_groupmembership_username_and_more.py @@ -5,15 +5,10 @@ class Migration(migrations.Migration): - dependencies = [ - ("gsr_booking", "0008_auto_20211112_1657"), - ] + dependencies = [("gsr_booking", "0008_auto_20211112_1657")] operations = [ - migrations.RemoveField( - model_name="groupmembership", - name="username", - ), + migrations.RemoveField(model_name="groupmembership", name="username"), migrations.AddField( model_name="groupmembership", name="is_wharton", diff --git a/backend/gsr_booking/migrations/0010_remove_gsrbooking_reminder_sent_and_more.py b/backend/gsr_booking/migrations/0010_remove_gsrbooking_reminder_sent_and_more.py index 2ef9af6e..96ee1c7f 100644 --- a/backend/gsr_booking/migrations/0010_remove_gsrbooking_reminder_sent_and_more.py +++ b/backend/gsr_booking/migrations/0010_remove_gsrbooking_reminder_sent_and_more.py @@ -5,18 +5,11 @@ class Migration(migrations.Migration): - dependencies = [ - ("gsr_booking", "0009_remove_groupmembership_username_and_more"), - ] + dependencies = [("gsr_booking", "0009_remove_groupmembership_username_and_more")] operations = [ - migrations.RemoveField( - model_name="gsrbooking", - name="reminder_sent", - ), + migrations.RemoveField(model_name="gsrbooking", name="reminder_sent"), migrations.AddField( - model_name="reservation", - name="reminder_sent", - field=models.BooleanField(default=False), + model_name="reservation", name="reminder_sent", field=models.BooleanField(default=False) ), ] diff --git a/backend/gsr_booking/migrations/0011_alter_reservation_group.py b/backend/gsr_booking/migrations/0011_alter_reservation_group.py index df639bad..9b7422a7 100644 --- a/backend/gsr_booking/migrations/0011_alter_reservation_group.py +++ b/backend/gsr_booking/migrations/0011_alter_reservation_group.py @@ -6,9 +6,7 @@ class Migration(migrations.Migration): - dependencies = [ - ("gsr_booking", "0010_remove_gsrbooking_reminder_sent_and_more"), - ] + dependencies = [("gsr_booking", "0010_remove_gsrbooking_reminder_sent_and_more")] operations = [ migrations.AlterField( @@ -20,5 +18,5 @@ class Migration(migrations.Migration): on_delete=django.db.models.deletion.CASCADE, to="gsr_booking.group", ), - ), + ) ] diff --git a/backend/gsr_booking/migrations/0012_gsr_in_use.py b/backend/gsr_booking/migrations/0012_gsr_in_use.py index d1e3879f..522505b9 100644 --- a/backend/gsr_booking/migrations/0012_gsr_in_use.py +++ b/backend/gsr_booking/migrations/0012_gsr_in_use.py @@ -5,14 +5,10 @@ class Migration(migrations.Migration): - dependencies = [ - ("gsr_booking", "0011_alter_reservation_group"), - ] + dependencies = [("gsr_booking", "0011_alter_reservation_group")] operations = [ migrations.AddField( - model_name="gsr", - name="in_use", - field=models.BooleanField(default=True), - ), + model_name="gsr", name="in_use", field=models.BooleanField(default=True) + ) ] diff --git a/backend/gsr_booking/models.py b/backend/gsr_booking/models.py index 003832c3..e2ef5b8f 100644 --- a/backend/gsr_booking/models.py +++ b/backend/gsr_booking/models.py @@ -1,17 +1,12 @@ -from typing import TYPE_CHECKING, Any +from typing import Any from django.contrib.auth import get_user_model from django.db import models from django.db.models import QuerySet from django.utils import timezone +from utils.types import UserType -if TYPE_CHECKING: - from django.contrib.auth.models import AbstractUser - - UserType = AbstractUser -else: - UserType = Any User = get_user_model() @@ -19,54 +14,65 @@ class GroupMembership(models.Model): # INVARIANT: either user or username should always be set. if user is not None, then the # username should the be username of the associated user. - user = models.ForeignKey( + user: models.ForeignKey = models.ForeignKey( User, on_delete=models.CASCADE, related_name="memberships", blank=True, null=True ) - group = models.ForeignKey("Group", on_delete=models.CASCADE, related_name="memberships") + group: models.ForeignKey = models.ForeignKey( + "Group", on_delete=models.CASCADE, related_name="memberships" + ) + group_id: int # When accepted is False, this is a request, otherwise this is an active membership. - accepted = models.BooleanField(default=False) + accepted: models.BooleanField = models.BooleanField(default=False) ADMIN = "A" MEMBER = "M" - type = models.CharField(max_length=10, choices=[(ADMIN, "Admin"), (MEMBER, "Member")]) + type: models.CharField = models.CharField( + max_length=10, choices=[(ADMIN, "Admin"), (MEMBER, "Member")] + ) - pennkey_allow = models.BooleanField(default=False) + pennkey_allow: models.BooleanField = models.BooleanField(default=False) - notifications = models.BooleanField(default=True) + notifications: models.BooleanField = models.BooleanField(default=True) - is_wharton = models.BooleanField(blank=True, null=True, default=None) + is_wharton: models.BooleanField = models.BooleanField(blank=True, null=True, default=None) @property - def is_invite(self): + def is_invite(self) -> bool: return not self.accepted - def __str__(self): + def __str__(self) -> str: return f"{self.user}<->{self.group}" - def save(self, *args, **kwargs): + def save(self, *args: Any, **kwargs: Any) -> None: # determines whether user is wharton or not if self.is_wharton is None: self.is_wharton = self.check_wharton() super().save(*args, **kwargs) - def check_wharton(self): - return WhartonGSRBooker.is_wharton(self.user) + def check_wharton(self) -> bool: + if check := WhartonGSRBooker.is_wharton(self.user): + return check + return False class Meta: verbose_name = "Group Membership" class Group(models.Model): - owner = models.ForeignKey(User, on_delete=models.CASCADE) - members = models.ManyToManyField(User, through=GroupMembership, related_name="booking_groups") + id: int + owner: models.ForeignKey = models.ForeignKey(User, on_delete=models.CASCADE) + members: models.ManyToManyField = models.ManyToManyField( + User, through=GroupMembership, related_name="booking_groups" + ) + memberships: QuerySet - name = models.CharField(max_length=255) - color = models.CharField(max_length=255) + name: models.CharField = models.CharField(max_length=255) + color: models.CharField = models.CharField(max_length=255) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) + created_at: models.DateTimeField = models.DateTimeField(auto_now_add=True) + updated_at: models.DateTimeField = models.DateTimeField(auto_now=True) ADMIN = "A" MEMBER = "M" @@ -74,15 +80,15 @@ class Group(models.Model): def __str__(self) -> str: return f"{self.name}-{self.pk}" - def has_member(self, user: "UserType") -> bool: + def has_member(self, user: UserType) -> bool: memberships = GroupMembership.objects.filter(group=self, user=user) return memberships.all().exists() - def has_admin(self, user: "UserType") -> bool: + def has_admin(self, user: UserType) -> bool: memberships = GroupMembership.objects.filter(group=self, accepted=True) return memberships.all().filter(type="A").filter(user=user).exists() - def get_pennkey_active_members(self): + def get_pennkey_active_members(self) -> list[UserType]: memberships = GroupMembership.objects.filter(group=self, accepted=True) pennkey_active_members_list = memberships.all().filter(pennkey_allow=True).all() return [member for member in pennkey_active_members_list] @@ -95,7 +101,7 @@ def save(self, *args: Any, **kwargs: Any) -> None: class GSRManager(models.Manager): - def get_queryset(self) -> QuerySet[Any]: + def get_queryset(self) -> QuerySet: return super().get_queryset().filter(in_use=True) @@ -105,41 +111,51 @@ class GSR(models.Model): KIND_LIBCAL = "LIBCAL" KIND_OPTIONS = ((KIND_WHARTON, "Wharton"), (KIND_LIBCAL, "Libcal")) - kind = models.CharField(max_length=7, choices=KIND_OPTIONS, default=KIND_LIBCAL) - lid = models.CharField(max_length=255) - gid = models.IntegerField(null=True) - name = models.CharField(max_length=255) - image_url = models.URLField() + kind: models.CharField = models.CharField( + max_length=7, choices=KIND_OPTIONS, default=KIND_LIBCAL + ) + id: int + lid: models.CharField = models.CharField(max_length=255) + gid: models.IntegerField = models.IntegerField(null=True) + name: models.CharField = models.CharField(max_length=255) + image_url: models.URLField = models.URLField() - in_use = models.BooleanField(default=True) + in_use: models.BooleanField = models.BooleanField(default=True) objects = GSRManager() - all_objects = models.Manager() # for admin page + all_objects: models.Manager = models.Manager() # for admin page def __str__(self) -> str: return f"{self.name}: {self.lid}-{self.gid}" class Reservation(models.Model): - start = models.DateTimeField(default=timezone.now) - end = models.DateTimeField(default=timezone.now) - creator = models.ForeignKey(User, on_delete=models.CASCADE) - group = models.ForeignKey(Group, on_delete=models.CASCADE, null=True, blank=True) - is_cancelled = models.BooleanField(default=False) - reminder_sent = models.BooleanField(default=False) + id: int + start: models.DateTimeField = models.DateTimeField(default=timezone.now) + end: models.DateTimeField = models.DateTimeField(default=timezone.now) + creator: models.ForeignKey = models.ForeignKey(User, on_delete=models.CASCADE) + group: models.ForeignKey = models.ForeignKey( + Group, on_delete=models.CASCADE, null=True, blank=True + ) + is_cancelled: models.BooleanField = models.BooleanField(default=False) + reminder_sent: models.BooleanField = models.BooleanField(default=False) + + gsrbooking_set: QuerySet class GSRBooking(models.Model): # TODO: change to non-null after reservations are created for current bookings - reservation = models.ForeignKey(Reservation, on_delete=models.CASCADE, null=True) - user = models.ForeignKey(User, on_delete=models.CASCADE, null=True) - booking_id = models.CharField(max_length=255, null=True, blank=True) - gsr = models.ForeignKey(GSR, on_delete=models.CASCADE) - room_id = models.IntegerField() - room_name = models.CharField(max_length=255) - start = models.DateTimeField(default=timezone.now) - end = models.DateTimeField(default=timezone.now) - is_cancelled = models.BooleanField(default=False) + reservation: models.ForeignKey = models.ForeignKey( + Reservation, on_delete=models.CASCADE, null=True + ) + user: models.ForeignKey = models.ForeignKey(User, on_delete=models.CASCADE, null=True) + booking_id: models.CharField = models.CharField(max_length=255, null=True, blank=True) + gsr: models.ForeignKey = models.ForeignKey(GSR, on_delete=models.CASCADE) + room_id: models.IntegerField = models.IntegerField() + room_name: models.CharField = models.CharField(max_length=255) + start: models.DateTimeField = models.DateTimeField(default=timezone.now) + end: models.DateTimeField = models.DateTimeField(default=timezone.now) + is_cancelled: models.BooleanField = models.BooleanField(default=False) def __str__(self) -> str: return f"{self.user} - {self.gsr.name} - {self.start} - {self.end}" diff --git a/backend/gsr_booking/serializers.py b/backend/gsr_booking/serializers.py index da1139a7..206ab816 100644 --- a/backend/gsr_booking/serializers.py +++ b/backend/gsr_booking/serializers.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Any, TypeAlias +from typing import Any, TypeAlias from django.contrib.auth import get_user_model from rest_framework import serializers @@ -6,13 +6,6 @@ from gsr_booking.models import GSR, Group, GroupMembership, GSRBooking -if TYPE_CHECKING: - from django.contrib.auth.models import AbstractUser - - UserType = AbstractUser -else: - UserType = Any - ValidatedData: TypeAlias = dict[str, Any] User = get_user_model() @@ -30,32 +23,21 @@ def get_is_wharton(self, obj: ValidatedData) -> bool: return obj["lid"] == 1 -class MiniUserSerializer(serializers.ModelSerializer): - class Meta: - model = User - fields = ["username", "first_name", "last_name"] - - class GroupMembershipSerializer(serializers.ModelSerializer): - user = MiniUserSerializer(read_only=True) - group = serializers.SlugRelatedField(slug_field="name", queryset=Group.objects.all()) - color = serializers.SlugRelatedField(slug_field="color", read_only=True, source="group") + group: serializers.SlugRelatedField = serializers.SlugRelatedField( + slug_field="name", queryset=Group.objects.all() + ) + color: serializers.SlugRelatedField = serializers.SlugRelatedField( + slug_field="color", read_only=True, source="group" + ) class Meta: model = GroupMembership - fields = [ - "user", - "group", - "type", - "pennkey_allow", - "notifications", - "id", - "color", - ] + fields = ["group", "type", "pennkey_allow", "notifications", "id", "color"] class GroupSerializer(serializers.ModelSerializer): - owner = serializers.SlugRelatedField( + owner: serializers.SlugRelatedField = serializers.SlugRelatedField( slug_field="username", queryset=User.objects.all(), required=False ) memberships = GroupMembershipSerializer(many=True, read_only=True) @@ -83,29 +65,6 @@ def to_internal_value(self, data: ValidatedData) -> None: return None # TODO: If you want to update based on BookingField, implement this. -class UserSerializer(serializers.ModelSerializer): - booking_groups = serializers.SerializerMethodField() - - def get_booking_groups(self, obj: "UserType") -> list[dict[str, Any]]: - result = [] - for membership in GroupMembership.objects.filter(accepted=True, user=obj): - result.append( - { - "name": membership.group.name, - "id": membership.group.id, - "color": membership.group.color, - "pennkey_allow": membership.pennkey_allow, - "notifications": membership.notifications, - } - ) - - return result - - class Meta: - model = User - fields = ["username", "booking_groups"] - - class GSRSerializer(serializers.ModelSerializer): class Meta: model = GSR diff --git a/backend/gsr_booking/urls.py b/backend/gsr_booking/urls.py index 7f6e2b01..a54a1e12 100644 --- a/backend/gsr_booking/urls.py +++ b/backend/gsr_booking/urls.py @@ -10,16 +10,16 @@ GroupMembershipViewSet, GroupViewSet, Locations, + MyMembershipViewSet, RecentGSRs, ReservationsView, - UserViewSet, ) from utils.cache import Cache router = routers.DefaultRouter() -router.register(r"users", UserViewSet) +router.register(r"mymemberships", MyMembershipViewSet, "mymemberships") router.register(r"membership", GroupMembershipViewSet) router.register(r"groups", GroupViewSet) diff --git a/backend/gsr_booking/views.py b/backend/gsr_booking/views.py index bf82d6c8..f5f2df5b 100644 --- a/backend/gsr_booking/views.py +++ b/backend/gsr_booking/views.py @@ -1,6 +1,5 @@ -from typing import TYPE_CHECKING, Any, Optional, cast +from typing import Optional -from django.contrib.auth import get_user_model from django.db.models import Manager, Prefetch, Q, QuerySet from django.http import HttpResponseForbidden from django.shortcuts import get_object_or_404 @@ -14,74 +13,32 @@ from gsr_booking.api_wrapper import GSRBooker, WhartonGSRBooker from gsr_booking.models import GSR, Group, GroupMembership, GSRBooking -from gsr_booking.serializers import ( - GroupMembershipSerializer, - GroupSerializer, - GSRSerializer, - UserSerializer, -) +from gsr_booking.serializers import GroupMembershipSerializer, GroupSerializer, GSRSerializer from pennmobile.analytics import Metric, record_analytics from utils.errors import APIError +from utils.types import get_user -if TYPE_CHECKING: - from django.contrib.auth.models import AbstractUser - - UserType = AbstractUser -else: - UserType = Any - -User = get_user_model() - - -# TODO: user model doesn't have a `booking_groups` attribute, so placing Any type for now -class UserViewSet(viewsets.ReadOnlyModelViewSet["UserType"]): - """ - Can specify `me` instead of the `username` to retrieve details on the current user. - """ - - queryset = User.objects.all().prefetch_related( - Prefetch("booking_groups", Group.objects.filter(memberships__accepted=True)) - ) +class MyMembershipViewSet(viewsets.ReadOnlyModelViewSet): permission_classes = [IsAuthenticated] - serializer_class = UserSerializer - lookup_field = "username" - filter_backends = [DjangoFilterBackend] - filterset_fields = ["username", "first_name", "last_name"] - - def get_object(self) -> "UserType": - lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field - param = self.kwargs[lookup_url_kwarg] - if param == "me": - return cast("UserType", self.request.user) - return super().get_object() - - def get_queryset(self) -> QuerySet["UserType", Manager["UserType"]]: - if not self.request.user.is_authenticated: - return User.objects.none() - - user: Any = self.request.user - queryset = User.objects.all() - queryset = queryset.prefetch_related( - Prefetch( - "memberships", - GroupMembership.objects.filter(group__in=user.booking_groups.all(), accepted=True), - ) - ) - return queryset + serializer_class = GroupMembershipSerializer + + def get_queryset(self) -> QuerySet[GroupMembership, Manager[GroupMembership]]: + return GroupMembership.objects.filter(user=self.request.user, accepted=True) - @action(detail=True, methods=["get"]) - def invites(self, request: Request, username: Optional[str] = None) -> Response: + @action(detail=False, methods=["get"]) + def invites(self, request: Request) -> Response: """ Retrieve all invites for a given user. """ - user = get_object_or_404(User, username=username) - request_user: Any = self.request.user + request_user = get_user(self.request) return Response( GroupMembershipSerializer( GroupMembership.objects.filter( - user=user, accepted=False, group__in=request_user.booking_groups.all() + user=get_user(request), + accepted=False, + group__in=request_user.booking_groups.all(), ), many=True, ).data @@ -96,7 +53,7 @@ class GroupMembershipViewSet(viewsets.ModelViewSet[GroupMembership]): serializer_class = GroupMembershipSerializer def get_queryset(self) -> QuerySet[GroupMembership, Manager[GroupMembership]]: - user: Any = self.request.user + user = get_user(self.request) if not user.is_authenticated: return GroupMembership.objects.none() @@ -118,7 +75,7 @@ def invite(self, request: Request) -> Response | HttpResponseForbidden: group = get_object_or_404(Group, pk=group_id) # don't invite when user already in group - if group.has_member(cast(Any, request.user)): + if group.has_member(get_user(request)): return HttpResponseForbidden() return Response({"message": "invite(s) sent."}) @@ -128,7 +85,7 @@ def accept( self, request: Request, pk: Optional[int] = None ) -> Response | HttpResponseForbidden: membership = get_object_or_404(GroupMembership, pk=pk, accepted=False) - user = cast("UserType", request.user) + user = get_user(request) if membership.user is None or membership.user != user: return HttpResponseForbidden() @@ -150,7 +107,7 @@ def decline( self, request: Request, pk: Optional[int] = None ) -> Response | HttpResponseForbidden: membership = get_object_or_404(GroupMembership, pk=pk, accepted=False) - if membership.user is None or membership.user != cast("UserType", request.user): + if membership.user is None or membership.user != get_user(request): return HttpResponseForbidden() if not membership.is_invite: return Response({"message": "cannot decline an invite that has been accepted."}, 400) @@ -170,7 +127,7 @@ class GroupViewSet(viewsets.ModelViewSet[Group]): permission_classes = [IsAuthenticated] def get_queryset(self) -> QuerySet[Group, Manager[Group]]: - user = cast("UserType", self.request.user) + user = get_user(self.request) if not user.is_authenticated: return Group.objects.none() return ( @@ -197,9 +154,8 @@ class RecentGSRs(generics.ListAPIView[GSR]): permission_classes = [IsAuthenticated] def get_queryset(self) -> QuerySet[GSR, Manager[GSR]]: - user = cast("UserType", self.request.user) return GSR.objects.filter( - id__in=GSRBooking.objects.filter(user=user, is_cancelled=False) + id__in=GSRBooking.objects.filter(user=get_user(self.request), is_cancelled=False) .distinct() .order_by("-end")[:2] .values_list("gsr", flat=True) @@ -211,7 +167,7 @@ class CheckWharton(APIView): permission_classes = [IsAuthenticated] def get(self, request: Request) -> Response: - user: Any = request.user + user = get_user(request) return Response( { "is_wharton": user.booking_groups.filter(name="Penn Labs").exists() @@ -231,13 +187,13 @@ class Availability(APIView): permission_classes = [IsAuthenticated] - def get(self, request: Request, lid: int, gid: int) -> Response: + def get(self, request: Request, lid: int, gid: str) -> Response: start = request.GET.get("start", None) end = request.GET.get("end", None) try: - user: Any = request.user + user = get_user(request) return Response( GSRBooker.get_availability( lid, @@ -263,7 +219,7 @@ def post(self, request: Request) -> Response: gid = request.data["gid"] room_id = request.data["id"] room_name = request.data["room_name"] - user: Any = request.user + user = get_user(request) try: GSRBooker.book_room( @@ -292,7 +248,7 @@ class CancelRoom(APIView): def post(self, request: Request) -> Response: booking_id = request.data["booking_id"] - user: Any = request.user + user = get_user(request) try: GSRBooker.cancel_room(booking_id, user) return Response({"detail": "success"}) @@ -308,7 +264,7 @@ class ReservationsView(APIView): permission_classes = [IsAuthenticated] def get(self, request: Request) -> Response: - user: Any = request.user + user = get_user(request) return Response( GSRBooker.get_reservations(user, user.booking_groups.filter(name="Penn Labs").first()) ) diff --git a/backend/laundry/api_wrapper.py b/backend/laundry/api_wrapper.py index 938e9a18..16321d9f 100644 --- a/backend/laundry/api_wrapper.py +++ b/backend/laundry/api_wrapper.py @@ -48,7 +48,7 @@ def parse_a_hall(hall_link: str) -> dict[str, Any]: washers = {"open": 0, "running": 0, "out_of_order": 0, "offline": 0, "time_remaining": []} dryers = {"open": 0, "running": 0, "out_of_order": 0, "offline": 0, "time_remaining": []} - detailed = [] + detailed: list[dict[str, Any]] = [] try: page = requests.get( diff --git a/backend/laundry/migrations/0002_auto_20210321_1105.py b/backend/laundry/migrations/0002_auto_20210321_1105.py index eed312d7..9497a9d9 100644 --- a/backend/laundry/migrations/0002_auto_20210321_1105.py +++ b/backend/laundry/migrations/0002_auto_20210321_1105.py @@ -6,14 +6,12 @@ class Migration(migrations.Migration): - dependencies = [ - ("laundry", "0001_initial"), - ] + dependencies = [("laundry", "0001_initial")] operations = [ migrations.AlterField( model_name="laundrysnapshot", name="date", field=models.DateTimeField(default=django.utils.timezone.now), - ), + ) ] diff --git a/backend/laundry/models.py b/backend/laundry/models.py index 4af9bd2a..148936e3 100644 --- a/backend/laundry/models.py +++ b/backend/laundry/models.py @@ -5,25 +5,25 @@ class LaundryRoom(models.Model): - hall_id = models.IntegerField(default=0) - name = models.CharField(max_length=255) - location = models.CharField(max_length=255) - total_washers = models.IntegerField(default=0) - total_dryers = models.IntegerField(default=0) + hall_id: models.IntegerField = models.IntegerField(default=0) + name: models.CharField = models.CharField(max_length=255) + location: models.CharField = models.CharField(max_length=255) + total_washers: models.IntegerField = models.IntegerField(default=0) + total_dryers: models.IntegerField = models.IntegerField(default=0) # Each Laundry Room has a UUID that we need to # access Penn API laundry data - uuid = models.UUIDField(default=uuid.uuid4) + uuid: models.UUIDField = models.UUIDField(default=uuid.uuid4) - def __str__(self): + def __str__(self) -> str: return f"Hall No. {self.hall_id} | {self.name}" class LaundrySnapshot(models.Model): - room = models.ForeignKey(LaundryRoom, on_delete=models.CASCADE, null=True) - date = models.DateTimeField(default=timezone.now) - available_washers = models.IntegerField() - available_dryers = models.IntegerField() + room: models.ForeignKey = models.ForeignKey(LaundryRoom, on_delete=models.CASCADE, null=True) + date: models.DateTimeField = models.DateTimeField(default=timezone.now) + available_washers: models.IntegerField = models.IntegerField() + available_dryers: models.IntegerField = models.IntegerField() - def __str__(self): - return f"Hall No. {self.room.hall_id} | {self.date.date()}" + def __str__(self) -> str: + return f"Hall No. {self.room.hall_id} | {self.date.date()}" # ignore: type[attr-defined] diff --git a/backend/laundry/views.py b/backend/laundry/views.py index 85d8698c..eccf04a1 100644 --- a/backend/laundry/views.py +++ b/backend/laundry/views.py @@ -1,8 +1,9 @@ import calendar import datetime +from typing import Any, Optional, cast from django.core.cache import cache -from django.db.models import Q, QuerySet +from django.db.models import Manager, Q, QuerySet from django.shortcuts import get_object_or_404 from django.utils import timezone from requests.exceptions import HTTPError @@ -16,6 +17,7 @@ from laundry.serializers import LaundryRoomSerializer from pennmobile.analytics import Metric, record_analytics from utils.cache import Cache +from utils.types import get_user class Ids(APIView): @@ -46,7 +48,7 @@ class MultipleHallInfo(APIView): def get(self, request: Request, hall_ids: str) -> Response: halls = [int(x) for x in hall_ids.split(",")] - output = {"rooms": []} + output: dict[str, Any] = {"rooms": []} for hall_id in halls: hall_data = hall_status(get_object_or_404(LaundryRoom, hall_id=hall_id)) @@ -54,7 +56,7 @@ def get(self, request: Request, hall_ids: str) -> Response: hall_data["usage_data"] = HallUsage.compute_usage(hall_id) output["rooms"].append(hall_data) - record_analytics(Metric.LAUNDRY_VIEWED, request.user.username) + record_analytics(Metric.LAUNDRY_VIEWED, get_user(request).username) return Response(output) @@ -64,10 +66,16 @@ class HallUsage(APIView): GET: returns usage data for dryers and washers of a particular hall """ - def safe_division(a: int | None, b: int | None) -> float | None: - return round(a / float(b), 3) if b > 0 else 0 + @staticmethod + def safe_division(a: Optional[int] = None, b: Optional[int] = None) -> float | None: + if a is None or b is None or b <= 0: + return 0.0 + return round(a / float(b), 3) - def get_snapshot_info(hall_id: int) -> tuple[LaundryRoom, QuerySet[LaundrySnapshot]]: + @staticmethod + def get_snapshot_info( + hall_id: int, + ) -> tuple[LaundryRoom, QuerySet[LaundrySnapshot, Manager[LaundrySnapshot]]]: # filters for LaundrySnapshots within timeframe room = get_object_or_404(LaundryRoom, hall_id=hall_id) @@ -84,7 +92,8 @@ def get_snapshot_info(hall_id: int) -> tuple[LaundryRoom, QuerySet[LaundrySnapsh snapshots = LaundrySnapshot.objects.filter(filter).order_by("-date") return (room, snapshots) - def compute_usage(hall_id: int) -> Response: + @staticmethod + def compute_usage(hall_id: int) -> dict[str, Any] | Response: try: (room, snapshots) = HallUsage.get_snapshot_info(hall_id) except ValueError: @@ -97,7 +106,8 @@ def compute_usage(hall_id: int) -> Response: min_date = timezone.localtime() max_date = timezone.localtime() - datetime.timedelta(days=30) - for snapshot in snapshots: + for snapshot_obj in snapshots.iterator(): + snapshot = cast(LaundrySnapshot, snapshot_obj) date = snapshot.date.astimezone() min_date = min(min_date, date) max_date = max(max_date, date) @@ -152,18 +162,20 @@ class Preferences(APIView): key = "laundry_preferences:{user_id}" def get(self, request: Request) -> Response: - key = self.key.format(user_id=request.user.id) + user = get_user(request) + key = self.key.format(user_id=user.id) cached_preferences = cache.get(key) if cached_preferences is None: - preferences = request.user.profile.laundry_preferences.all() + preferences = user.profile.laundry_preferences.all() cached_preferences = preferences.values_list("hall_id", flat=True) cache.set(key, cached_preferences, Cache.MONTH) return Response({"rooms": cached_preferences}) def post(self, request: Request) -> Response: - key = self.key.format(user_id=request.user.id) - profile = request.user.profile + user = get_user(request) + key = self.key.format(user_id=user.id) + profile = user.profile preferences = profile.laundry_preferences if "rooms" not in request.data: return Response({"success": False, "error": "No rooms provided"}, status=400) diff --git a/backend/manage.py b/backend/manage.py index 4d8cecf0..8b4e3280 100755 --- a/backend/manage.py +++ b/backend/manage.py @@ -4,7 +4,7 @@ import sys -def main(): +def main() -> None: os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pennmobile.settings.development") try: from django.core.management import execute_from_command_line diff --git a/backend/penndata/admin.py b/backend/penndata/admin.py index 2ba02fc3..4219fcf1 100644 --- a/backend/penndata/admin.py +++ b/backend/penndata/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from django.utils.html import escape, mark_safe -from django.utils.safestring import SafeText +from django.utils.html import escape +from django.utils.safestring import SafeText, mark_safe from penndata.models import ( AnalyticsEvent, @@ -16,7 +16,7 @@ class FitnessRoomAdmin(admin.ModelAdmin): def image_tag(self, instance: FitnessRoom) -> SafeText: return mark_safe('' % escape(instance.image_url)) - image_tag.short_description = "Fitness Room Image" + image_tag.short_description = "Fitness Room Image" # type: ignore[attr-defined] readonly_fields = ("image_tag",) diff --git a/backend/penndata/management/commands/get_college_house_events.py b/backend/penndata/management/commands/get_college_house_events.py index 7773f91b..b0c2aaba 100644 --- a/backend/penndata/management/commands/get_college_house_events.py +++ b/backend/penndata/management/commands/get_college_house_events.py @@ -51,7 +51,7 @@ def scrape_details(self, event_url: str) -> tuple[ resp = requests.get(event_url) except ConnectionError: print("Error:", ConnectionError) - return None + return None, None, None, None, None soup = BeautifulSoup(resp.text, "html.parser") location = ( diff --git a/backend/penndata/management/commands/get_fitness_snapshot.py b/backend/penndata/management/commands/get_fitness_snapshot.py index 09036cc0..a684c8cd 100644 --- a/backend/penndata/management/commands/get_fitness_snapshot.py +++ b/backend/penndata/management/commands/get_fitness_snapshot.py @@ -1,5 +1,5 @@ import datetime -from typing import Any +from typing import Any, Optional import requests from bs4 import BeautifulSoup @@ -14,7 +14,7 @@ def cap_string(s: str) -> str: return " ".join([word[0].upper() + word[1:] for word in s.split()]) -def get_usages() -> tuple[dict[str, dict[str, int | float]], datetime.datetime]: +def get_usages() -> tuple[Optional[dict[str, dict[str, int | float]]], datetime.datetime]: # count/capacities default to 0 since spreadsheet number appears blank if no one there locations = [ @@ -28,7 +28,9 @@ def get_usages() -> tuple[dict[str, dict[str, int | float]], datetime.datetime]: "Pool-Shallow", "Pool-Deep", ] - usages = {location: {"count": 0, "capacity": 0} for location in locations} + usages: dict[str, dict[str, int | float]] = { + location: {"count": 0, "capacity": 0} for location in locations + } date = timezone.localtime() # default if can't get date from spreadsheet @@ -42,12 +44,12 @@ def get_usages() -> tuple[dict[str, dict[str, int | float]], datetime.datetime]: ) ) except ConnectionError: - return None + return None, date html = resp.content.decode("utf8") soup = BeautifulSoup(html, "html5lib") if not (embedded_spreadsheet := soup.find("tbody")): - return None + return None, date table_rows = embedded_spreadsheet.findChildren("tr") for i, row in enumerate(table_rows): @@ -77,6 +79,10 @@ def handle(self, *args: Any, **kwargs: Any) -> None: self.stdout.write("FitnessSnapshots already exist for this date!") return + if not usage_by_location: + self.stdout.write("Failed to get usages from spreadsheet!") + return + FitnessSnapshot.objects.bulk_create( [ FitnessSnapshot( diff --git a/backend/penndata/management/commands/get_penn_today_events.py b/backend/penndata/management/commands/get_penn_today_events.py index 8076bd1d..3f88259f 100644 --- a/backend/penndata/management/commands/get_penn_today_events.py +++ b/backend/penndata/management/commands/get_penn_today_events.py @@ -2,7 +2,7 @@ from typing import Any, Optional from urllib.parse import urljoin -from bs4 import BeautifulSoup +from bs4 import BeautifulSoup, Tag from django.core.management.base import BaseCommand from django.utils import timezone from selenium import webdriver @@ -55,60 +55,24 @@ def handle(self, *args: Any, **kwargs: Any) -> None: "p", class_="tease__meta--sm", string=lambda x: "Through" in str(x) ) - if start_date_str == "02/29" or start_date_str == "2/29": - # If it's February 29th - start_date = datetime.datetime.strptime("02/28", "%m/%d").replace(year=current_year) - if start_date.month < current_month: - # If scraped month is before current month, increment year - start_date = start_date.replace(year=current_year + 1) - start_date = start_date + datetime.timedelta(days=1) - else: - start_date = datetime.datetime.strptime(start_date_str, "%m/%d").replace( - year=current_year - ) - if start_date.month < current_month: - # If scraped month is before current month, increment year - start_date = start_date.replace(year=current_year + 1) - print(start_date_str) - if ALL_DAY in start_time_str.lower(): - start_time = datetime.time(0, 0) - else: - start_time = datetime.datetime.strptime(start_time_str, "%I:%M%p").time() - start_date = datetime.datetime.combine(start_date, start_time) + start_date = self._parse_start_date(start_date_str, current_month, current_year) + start_time = self._parse_start_time(start_time_str) + start_datetime = datetime.datetime.combine(start_date, start_time) - if start_date > now + datetime.timedelta(days=31): + if start_datetime > now + datetime.timedelta(days=31): continue event_url = urljoin(PENN_TODAY_WEBSITE, article.find("a", class_="tease__link")["href"]) - - end_time = self.get_end_time(event_url) - if end_time is not None: - if end_date_elem: # end date and end time - end_date_str = end_date_elem.text.strip().split(" ")[-1] - end_date = datetime.datetime.strptime(end_date_str, "%m/%d/%Y") - end_time = datetime.datetime.strptime(end_time, "%I:%M %p").time() - end_date = datetime.datetime.combine(end_date, end_time) - else: # no end date but end time - end_time = datetime.datetime.strptime(end_time, "%I:%M %p").time() - end_date = datetime.datetime.combine(start_date, end_time) - else: - end_of_day = datetime.time(23, 59, 59) - if end_date_elem: # end date but no end time - end_date_str = end_date_elem.text.strip().split(" ")[-1] - end_date = datetime.datetime.combine( - datetime.datetime.strptime(end_date_str, "%m/%d/%Y"), end_of_day - ) - - else: # no end date or end time - end_date = datetime.datetime.combine(start_date, end_of_day) + end_time_str = self.get_end_time(event_url) + end_datetime = self._calculate_end_datetime(end_time_str, end_date_elem, start_datetime) Event.objects.update_or_create( name=name, defaults={ "event_type": Event.TYPE_PENN_TODAY, "image_url": None, - "start": timezone.make_aware(start_date), - "end": timezone.make_aware(end_date), + "start": timezone.make_aware(start_datetime), + "end": timezone.make_aware(end_datetime), "location": location, "website": event_url, "description": description, @@ -118,7 +82,7 @@ def handle(self, *args: Any, **kwargs: Any) -> None: self.stdout.write("Uploaded Penn Today Events!") - def connect_and_parse_html(self, event_url: str, condition: EC) -> Optional[str]: + def connect_and_parse_html(self, event_url: str, condition: EC) -> Optional[BeautifulSoup]: try: options = Options() options.add_argument("--headless") @@ -143,9 +107,14 @@ def get_end_time(self, event_url: str) -> Optional[str]: event_url, EC.presence_of_element_located((By.CLASS_NAME, "event__topper-content")) ) - end_time_range_str = ( - end_time_soup.find("p", class_="event__meta event__time").text.strip().replace(".", "") - ) + if not end_time_soup: + return None + + time_element = end_time_soup.find("p", class_="event__meta event__time") + if not time_element: + return None + + end_time_range_str = time_element.text.strip().replace(".", "") if ( not end_time_range_str @@ -155,3 +124,45 @@ def get_end_time(self, event_url: str) -> Optional[str]: return None # No end time if the event is all day return times[1] + + def _parse_start_date( + self, date_str: str, current_month: int, current_year: int + ) -> datetime.date: + if date_str in ("02/29", "2/29"): + start_date = datetime.datetime.strptime("02/28", "%m/%d").replace(year=current_year) + if start_date.month < current_month: + start_date = start_date.replace(year=current_year + 1) + return (start_date + datetime.timedelta(days=1)).date() + + start_date = datetime.datetime.strptime(date_str, "%m/%d").replace(year=current_year) + if start_date.month < current_month: + start_date = start_date.replace(year=current_year + 1) + return start_date.date() + + def _parse_start_time(self, time_str: str) -> datetime.time: + if ALL_DAY in time_str.lower(): + return datetime.time(0, 0) + return datetime.datetime.strptime(time_str, "%I:%M%p").time() + + def _calculate_end_datetime( + self, + end_time_str: Optional[str], + end_date_elem: Optional[Tag], + start_datetime: datetime.datetime, + ) -> datetime.datetime: + end_of_day = datetime.time(23, 59, 59) + + if end_time_str: + end_time = datetime.datetime.strptime(end_time_str, "%I:%M %p").time() + if end_date_elem: + end_date_str = end_date_elem.text.strip().split(" ")[-1] + end_date = datetime.datetime.strptime(end_date_str, "%m/%d/%Y").date() + return datetime.datetime.combine(end_date, end_time) + return datetime.datetime.combine(start_datetime.date(), end_time) + + if end_date_elem: + end_date_str = end_date_elem.text.strip().split(" ")[-1] + end_date = datetime.datetime.strptime(end_date_str, "%m/%d/%Y").date() + return datetime.datetime.combine(end_date, end_of_day) + + return datetime.datetime.combine(start_datetime.date(), end_of_day) diff --git a/backend/penndata/management/commands/get_venture_events.py b/backend/penndata/management/commands/get_venture_events.py index 78cdf00d..ef9e852a 100644 --- a/backend/penndata/management/commands/get_venture_events.py +++ b/backend/penndata/management/commands/get_venture_events.py @@ -77,7 +77,7 @@ def handle(self, *args: Any, **kwargs: Any) -> None: ) # events are ordered from future to past, so break once we find a past event - if event_start_datetime < now.replace(tzinfo=None): + if event_start_datetime and event_start_datetime < now.replace(tzinfo=None): break if title := event.find("div", class_="PromoSearchResultEvent-title"): diff --git a/backend/penndata/management/commands/get_wharton_events.py b/backend/penndata/management/commands/get_wharton_events.py index 360cbb5e..81a46f0d 100644 --- a/backend/penndata/management/commands/get_wharton_events.py +++ b/backend/penndata/management/commands/get_wharton_events.py @@ -1,6 +1,6 @@ import datetime import re -from typing import Any +from typing import Any, Optional import pytz import requests @@ -36,8 +36,12 @@ def handle(self, *args: Any, **kwargs: Any) -> None: match = re.match(r"(\w+\s+\d+) \| (\d{1,2}:\d{2} [AP]M) - (\d{1,2}:\d{2} [AP]M)", info) if match: _, start_time, end_time = match.groups() - start_time_obj = datetime.datetime.strptime(start_time, "%I:%M %p") - end_time_obj = datetime.datetime.strptime(end_time, "%I:%M %p") + start_time_obj: Optional[datetime.datetime] = datetime.datetime.strptime( + start_time, "%I:%M %p" + ) + end_time_obj: Optional[datetime.datetime] = datetime.datetime.strptime( + end_time, "%I:%M %p" + ) else: # event has start and end times on different dates match = re.match( diff --git a/backend/penndata/migrations/0001_initial.py b/backend/penndata/migrations/0001_initial.py index 97305072..c2551529 100644 --- a/backend/penndata/migrations/0001_initial.py +++ b/backend/penndata/migrations/0001_initial.py @@ -29,5 +29,5 @@ class Migration(migrations.Migration): ("website", models.URLField(max_length=255, null=True)), ("facebook", models.URLField(max_length=255, null=True)), ], - ), + ) ] diff --git a/backend/penndata/migrations/0002_homepageorder.py b/backend/penndata/migrations/0002_homepageorder.py index 639435e2..83abb355 100644 --- a/backend/penndata/migrations/0002_homepageorder.py +++ b/backend/penndata/migrations/0002_homepageorder.py @@ -5,9 +5,7 @@ class Migration(migrations.Migration): - dependencies = [ - ("penndata", "0001_initial"), - ] + dependencies = [("penndata", "0001_initial")] operations = [ migrations.CreateModel( @@ -22,5 +20,5 @@ class Migration(migrations.Migration): ("cell", models.CharField(max_length=255)), ("rank", models.IntegerField()), ], - ), + ) ] diff --git a/backend/penndata/migrations/0003_analyticsevent.py b/backend/penndata/migrations/0003_analyticsevent.py index 7ba82e52..ec59efa7 100644 --- a/backend/penndata/migrations/0003_analyticsevent.py +++ b/backend/penndata/migrations/0003_analyticsevent.py @@ -47,5 +47,5 @@ class Migration(migrations.Migration): ), ), ], - ), + ) ] diff --git a/backend/penndata/migrations/0004_analyticsevent_data.py b/backend/penndata/migrations/0004_analyticsevent_data.py index dc3aa7cd..04cc6ba5 100644 --- a/backend/penndata/migrations/0004_analyticsevent_data.py +++ b/backend/penndata/migrations/0004_analyticsevent_data.py @@ -5,14 +5,12 @@ class Migration(migrations.Migration): - dependencies = [ - ("penndata", "0003_analyticsevent"), - ] + dependencies = [("penndata", "0003_analyticsevent")] operations = [ migrations.AddField( model_name="analyticsevent", name="data", field=models.CharField(max_length=255, null=True), - ), + ) ] diff --git a/backend/penndata/migrations/0005_fitnessroom_fitnesssnapshot.py b/backend/penndata/migrations/0005_fitnessroom_fitnesssnapshot.py index d671cc13..a5f8118f 100644 --- a/backend/penndata/migrations/0005_fitnessroom_fitnesssnapshot.py +++ b/backend/penndata/migrations/0005_fitnessroom_fitnesssnapshot.py @@ -7,9 +7,7 @@ class Migration(migrations.Migration): - dependencies = [ - ("penndata", "0004_analyticsevent_data"), - ] + dependencies = [("penndata", "0004_analyticsevent_data")] operations = [ migrations.CreateModel( diff --git a/backend/penndata/migrations/0006_fitnesssnapshot_capacity.py b/backend/penndata/migrations/0006_fitnesssnapshot_capacity.py index fa144d61..ce1e12f0 100644 --- a/backend/penndata/migrations/0006_fitnesssnapshot_capacity.py +++ b/backend/penndata/migrations/0006_fitnesssnapshot_capacity.py @@ -5,14 +5,10 @@ class Migration(migrations.Migration): - dependencies = [ - ("penndata", "0005_fitnessroom_fitnesssnapshot"), - ] + dependencies = [("penndata", "0005_fitnessroom_fitnesssnapshot")] operations = [ migrations.AddField( - model_name="fitnesssnapshot", - name="capacity", - field=models.FloatField(null=True), - ), + model_name="fitnesssnapshot", name="capacity", field=models.FloatField(null=True) + ) ] diff --git a/backend/penndata/migrations/0007_fitnessroom_image_url.py b/backend/penndata/migrations/0007_fitnessroom_image_url.py index 44f89470..3874cff3 100644 --- a/backend/penndata/migrations/0007_fitnessroom_image_url.py +++ b/backend/penndata/migrations/0007_fitnessroom_image_url.py @@ -5,9 +5,7 @@ class Migration(migrations.Migration): - dependencies = [ - ("penndata", "0006_fitnesssnapshot_capacity"), - ] + dependencies = [("penndata", "0006_fitnesssnapshot_capacity")] operations = [ migrations.AddField( @@ -15,5 +13,5 @@ class Migration(migrations.Migration): name="image_url", field=models.URLField(default=""), preserve_default=False, - ), + ) ] diff --git a/backend/penndata/migrations/0008_calendarevent.py b/backend/penndata/migrations/0008_calendarevent.py index e067e9bb..0d13f565 100644 --- a/backend/penndata/migrations/0008_calendarevent.py +++ b/backend/penndata/migrations/0008_calendarevent.py @@ -5,9 +5,7 @@ class Migration(migrations.Migration): - dependencies = [ - ("penndata", "0007_fitnessroom_image_url"), - ] + dependencies = [("penndata", "0007_fitnessroom_image_url")] operations = [ migrations.CreateModel( @@ -23,5 +21,5 @@ class Migration(migrations.Migration): ("date", models.CharField(blank=True, max_length=50, null=True)), ("date_obj", models.DateTimeField(blank=True, null=True)), ], - ), + ) ] diff --git a/backend/penndata/migrations/0009_auto_20240223_1820.py b/backend/penndata/migrations/0009_auto_20240223_1820.py index c615f12f..8327da1f 100644 --- a/backend/penndata/migrations/0009_auto_20240223_1820.py +++ b/backend/penndata/migrations/0009_auto_20240223_1820.py @@ -5,48 +5,25 @@ class Migration(migrations.Migration): - dependencies = [ - ("penndata", "0008_calendarevent"), - ] + dependencies = [("penndata", "0008_calendarevent")] operations = [ - migrations.RenameField( - model_name="event", - old_name="start_time", - new_name="start", - ), - migrations.RemoveField( - model_name="event", - name="end_time", - ), - migrations.AddField( - model_name="event", - name="end", - field=models.DateTimeField(null=True), - ), + migrations.RenameField(model_name="event", old_name="start_time", new_name="start"), + migrations.RemoveField(model_name="event", name="end_time"), + migrations.AddField(model_name="event", name="end", field=models.DateTimeField(null=True)), migrations.AddField( - model_name="event", - name="location", - field=models.CharField(max_length=255, null=True), + model_name="event", name="location", field=models.CharField(max_length=255, null=True) ), migrations.AlterField( - model_name="event", - name="description", - field=models.TextField(null=True), + model_name="event", name="description", field=models.TextField(null=True) ), migrations.AlterField( - model_name="event", - name="email", - field=models.CharField(max_length=255, null=True), + model_name="event", name="email", field=models.CharField(max_length=255, null=True) ), migrations.AlterField( - model_name="event", - name="event_type", - field=models.CharField(max_length=255, null=True), + model_name="event", name="event_type", field=models.CharField(max_length=255, null=True) ), migrations.AlterField( - model_name="event", - name="image_url", - field=models.URLField(null=True), + model_name="event", name="image_url", field=models.URLField(null=True) ), ] diff --git a/backend/penndata/migrations/0010_auto_20240228_0150.py b/backend/penndata/migrations/0010_auto_20240228_0150.py index 9e756298..9cf74601 100644 --- a/backend/penndata/migrations/0010_auto_20240228_0150.py +++ b/backend/penndata/migrations/0010_auto_20240228_0150.py @@ -5,19 +5,12 @@ class Migration(migrations.Migration): - dependencies = [ - ("penndata", "0009_auto_20240223_1820"), - ] + dependencies = [("penndata", "0009_auto_20240223_1820")] operations = [ - migrations.RemoveField( - model_name="event", - name="facebook", - ), + migrations.RemoveField(model_name="event", name="facebook"), migrations.AlterField( - model_name="event", - name="description", - field=models.TextField(blank=True, null=True), + model_name="event", name="description", field=models.TextField(blank=True, null=True) ), migrations.AlterField( model_name="event", @@ -25,9 +18,7 @@ class Migration(migrations.Migration): field=models.CharField(blank=True, max_length=255, null=True), ), migrations.AlterField( - model_name="event", - name="end", - field=models.DateTimeField(blank=True, null=True), + model_name="event", name="end", field=models.DateTimeField(blank=True, null=True) ), migrations.AlterField( model_name="event", @@ -35,9 +26,7 @@ class Migration(migrations.Migration): field=models.CharField(blank=True, max_length=255, null=True), ), migrations.AlterField( - model_name="event", - name="image_url", - field=models.URLField(blank=True, null=True), + model_name="event", name="image_url", field=models.URLField(blank=True, null=True) ), migrations.AlterField( model_name="event", diff --git a/backend/penndata/migrations/0011_alter_event_event_type_alter_event_start.py b/backend/penndata/migrations/0011_alter_event_event_type_alter_event_start.py index e9e00be9..105b4335 100644 --- a/backend/penndata/migrations/0011_alter_event_event_type_alter_event_start.py +++ b/backend/penndata/migrations/0011_alter_event_event_type_alter_event_start.py @@ -5,9 +5,7 @@ class Migration(migrations.Migration): - dependencies = [ - ("penndata", "0010_auto_20240228_0150"), - ] + dependencies = [("penndata", "0010_auto_20240228_0150")] operations = [ migrations.AlterField( @@ -41,8 +39,6 @@ class Migration(migrations.Migration): ), ), migrations.AlterField( - model_name="event", - name="start", - field=models.DateTimeField(blank=True, null=True), + model_name="event", name="start", field=models.DateTimeField(blank=True, null=True) ), ] diff --git a/backend/penndata/migrations/0012_alter_event_event_type.py b/backend/penndata/migrations/0012_alter_event_event_type.py index a211bce8..f0c97461 100644 --- a/backend/penndata/migrations/0012_alter_event_event_type.py +++ b/backend/penndata/migrations/0012_alter_event_event_type.py @@ -5,9 +5,7 @@ class Migration(migrations.Migration): - dependencies = [ - ("penndata", "0011_alter_event_event_type_alter_event_start"), - ] + dependencies = [("penndata", "0011_alter_event_event_type_alter_event_start")] operations = [ migrations.AlterField( @@ -40,5 +38,5 @@ class Migration(migrations.Migration): max_length=63, null=True, ), - ), + ) ] diff --git a/backend/penndata/models.py b/backend/penndata/models.py index f1d9122e..9d9b41c3 100644 --- a/backend/penndata/models.py +++ b/backend/penndata/models.py @@ -51,65 +51,66 @@ class Event(models.Model): (TYPE_STOUFFER_COLLEGE_HOUSE, "Stouffer College House"), ) - event_type = models.CharField( + event_type: models.CharField = models.CharField( max_length=63, choices=TYPE_CHOICES, default=None, null=True, blank=True ) - name = models.CharField(max_length=255) - description = models.TextField(null=True, blank=True) - image_url = models.URLField(null=True, blank=True) - start = models.DateTimeField(null=True, blank=True) - end = models.DateTimeField(null=True, blank=True) - location = models.CharField(max_length=255, null=True, blank=True) - email = models.CharField(max_length=255, null=True, blank=True) - website = models.URLField(max_length=255, null=True, blank=True) + name: models.CharField = models.CharField(max_length=255) + description: models.TextField = models.TextField(null=True, blank=True) + image_url: models.URLField = models.URLField(null=True, blank=True) + start: models.DateTimeField = models.DateTimeField(null=True, blank=True) + end: models.DateTimeField = models.DateTimeField(null=True, blank=True) + location: models.CharField = models.CharField(max_length=255, null=True, blank=True) + email: models.CharField = models.CharField(max_length=255, null=True, blank=True) + website: models.URLField = models.URLField(max_length=255, null=True, blank=True) class HomePageOrder(models.Model): - cell = models.CharField(max_length=255) - rank = models.IntegerField() + cell: models.CharField = models.CharField(max_length=255) + rank: models.IntegerField = models.IntegerField() - def __str__(self): + def __str__(self) -> str: return self.cell class FitnessRoom(models.Model): - name = models.CharField(max_length=255) - image_url = models.URLField() + id: int + name: models.CharField = models.CharField(max_length=255) + image_url: models.URLField = models.URLField() - def __str__(self): + def __str__(self) -> str: return str(self.name) class FitnessSnapshot(models.Model): - room = models.ForeignKey(FitnessRoom, on_delete=models.CASCADE, null=True) - date = models.DateTimeField(default=timezone.now) - count = models.IntegerField() - capacity = models.FloatField(null=True) + room: models.ForeignKey = models.ForeignKey(FitnessRoom, on_delete=models.CASCADE, null=True) + date: models.DateTimeField = models.DateTimeField(default=timezone.now) + count: models.IntegerField = models.IntegerField() + capacity: models.FloatField = models.FloatField(null=True) - def __str__(self): + def __str__(self) -> str: return f"Room Name: {self.room.name} | {self.date.date()}" class AnalyticsEvent(models.Model): - user = models.ForeignKey(User, on_delete=models.CASCADE) - created_at = models.DateTimeField(default=timezone.now) - cell_type = models.CharField(max_length=255) - index = models.IntegerField(default=0) - post = models.ForeignKey(Post, on_delete=models.CASCADE, null=True) - poll = models.ForeignKey(Poll, on_delete=models.CASCADE, null=True) - is_interaction = models.BooleanField(default=False) - data = models.CharField(max_length=255, null=True) - - def __str__(self): + user: models.ForeignKey = models.ForeignKey(User, on_delete=models.CASCADE) + created_at: models.DateTimeField = models.DateTimeField(default=timezone.now) + cell_type: models.CharField = models.CharField(max_length=255) + index: models.IntegerField = models.IntegerField(default=0) + post: models.ForeignKey = models.ForeignKey(Post, on_delete=models.CASCADE, null=True) + poll: models.ForeignKey = models.ForeignKey(Poll, on_delete=models.CASCADE, null=True) + is_interaction: models.BooleanField = models.BooleanField(default=False) + data: models.CharField = models.CharField(max_length=255, null=True) + + def __str__(self) -> str: return f"{self.cell_type}-{self.user.username}" class CalendarEvent(models.Model): - event = models.CharField(max_length=255) - date = models.CharField(max_length=50, null=True, blank=True) + event: models.CharField = models.CharField(max_length=255) + date: models.CharField = models.CharField(max_length=50, null=True, blank=True) # NOTE: This is bad practice, though is necessary for the time being # since frontends use the string date field - date_obj = models.DateTimeField(null=True, blank=True) + date_obj: models.DateTimeField = models.DateTimeField(null=True, blank=True) - def __str__(self): + def __str__(self) -> str: return f"{self.date}-{self.event}" diff --git a/backend/penndata/serializers.py b/backend/penndata/serializers.py index 51933e26..9dca83c0 100644 --- a/backend/penndata/serializers.py +++ b/backend/penndata/serializers.py @@ -1,4 +1,4 @@ -from typing import Any, TypeAlias +from typing import Any from rest_framework import serializers @@ -12,9 +12,6 @@ ) -ValidatedData: TypeAlias = dict[str, Any] - - class EventSerializer(serializers.ModelSerializer): class Meta: model = Event @@ -63,7 +60,7 @@ class Meta: model = AnalyticsEvent fields = ("created_at", "cell_type", "index", "post", "poll", "is_interaction") - def create(self, validated_data: ValidatedData) -> AnalyticsEvent: + def create(self, validated_data: dict[str, Any]) -> AnalyticsEvent: validated_data["user"] = self.context["request"].user if validated_data["poll"] and validated_data["post"]: raise serializers.ValidationError( diff --git a/backend/penndata/views.py b/backend/penndata/views.py index 8b722f68..b8a70d96 100644 --- a/backend/penndata/views.py +++ b/backend/penndata/views.py @@ -1,6 +1,6 @@ import datetime from datetime import timedelta -from typing import Any, TypeAlias +from typing import Any, Optional, Sequence, TypeAlias, cast import requests from bs4 import BeautifulSoup @@ -29,9 +29,9 @@ FitnessRoomSerializer, HomePageOrderSerializer, ) +from utils.types import get_user -ValidatedData: TypeAlias = dict[str, Any] CalendarEventList: TypeAlias = QuerySet[CalendarEvent, Manager[CalendarEvent]] EventList: TypeAlias = QuerySet[Event, Manager[Event]] HomePageOrderList: TypeAlias = QuerySet[HomePageOrder, Manager[HomePageOrder]] @@ -43,7 +43,7 @@ class News(APIView): """ def get_article(self) -> dict[str, Any] | None: - article = {"source": "The Daily Pennsylvanian"} + article: dict[str, Any] = {"source": "The Daily Pennsylvanian"} try: headers = { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_5) AppleWebKit/537.36 " @@ -161,7 +161,7 @@ class HomePage(APIView): class Cell: def __init__( - self, myType: str, myInfo: ValidatedData | None = None, myWeight: int = 0 + self, myType: str, myInfo: Optional[dict[str, Any]] = None, myWeight: int = 0 ) -> None: self.type = myType self.info = myInfo @@ -174,7 +174,7 @@ def get(self, request: Request) -> Response: # NOTE: accept arguments: ?version= - profile = request.user.profile + profile = get_user(request).profile # TODO: add user's GSR reservations to Response # TODO: add user's courses to Response @@ -216,7 +216,7 @@ def get(self, request: Request) -> Response: # cells.append(self.Cell("calendar", {"calendar": Calendar.get_calendar(self)}, 40)) # adds front page article of DP - cells.append(self.Cell("news", {"article": News.get_article(self)}, 50)) + cells.append(self.Cell("news", {"article": News().get_article()}, 50)) # sorts by cell weight cells.sort(key=lambda x: x.weight, reverse=True) @@ -243,7 +243,7 @@ class FitnessRoomView(generics.ListAPIView): } def get(self, request: Request) -> Response: - response = super().get(self, request) + response = super().get(request) # also add last_updated and open/close times to each room in response for room in response.data: ss = FitnessSnapshot.objects.filter(room__id=room["id"]).order_by("-date").first() @@ -263,17 +263,17 @@ def get(self, request: Request) -> Response: class FitnessUsage(APIView): - def safe_add(self, a: int | None, b: int | None) -> int | None: + def safe_add(self, a: Optional[float] = None, b: Optional[float] = None) -> Optional[float]: return None if a is None and b is None else (a or 0) + (b or 0) def linear_interpolate( self, - before_val: int | None, - after_val: int | None, + before_val: int | float, + after_val: int | float, before_date: datetime.datetime, current_date: datetime.datetime, after_date: datetime.datetime, - ) -> int | None: + ) -> int | float: return ( before_val + (after_val - before_val) @@ -283,7 +283,7 @@ def linear_interpolate( def get_usage_on_date( self, room: FitnessRoom, date: datetime.date, field: str - ) -> list[int | None]: + ) -> Sequence[Optional[int | float]]: """ Returns the number of people in the fitness center on a given date per hour """ @@ -298,7 +298,7 @@ def get_usage_on_date( snapshots = FitnessSnapshot.objects.filter(room=room, date__date=date) # For usage, None represents no data - usage = [0] * 24 + usage: list[Optional[float]] = [0] * 24 for hour in range(open, close + 1): # consider the :30 mark of each hour hour_date = timezone.make_aware(datetime.datetime.combine(date, datetime.time(hour))) @@ -307,8 +307,8 @@ def get_usage_on_date( before = snapshots.filter(date__lte=hour_date).order_by("-date").first() after = snapshots.filter(date__gte=hour_date).order_by("date").first() - before_date, before_val = getattr(before, "date", None), getattr(before, field, None) - after_date, after_val = getattr(after, "date", None), getattr(after, field, None) + before_date, before_val = getattr(before, "date", None), getattr(before, field, 0) + after_date, after_val = getattr(after, "date", None), getattr(after, field, 0) # This condition should only activate during morning times if before is None: @@ -322,7 +322,7 @@ def get_usage_on_date( if date == timezone.localtime().date(): # Set value to None if the last retrieved data was # over 2 hours old to avoid extrapolation - if hour_date - datetime.timedelta(hours=1) > before_date: + if before_date and hour_date - datetime.timedelta(hours=1) > before_date: for i in range(hour, 24): usage[i] = None break @@ -334,21 +334,29 @@ def get_usage_on_date( + datetime.timedelta(minutes=30), 0, ) - - usage[hour] = ( - self.linear_interpolate(before_val, after_val, before_date, hour_date, after_date) - if before_date != after_date - else after_val - ) + if before_date and after_date: + usage[hour] = ( + self.linear_interpolate( + before_val, + after_val, + cast(datetime.datetime, before_date), + hour_date, + cast(datetime.datetime, after_date), + ) + if before_date != after_date + else (after_val) + ) if all(amt == 0 for amt in usage): # location probably closed - don't count in aggregate return [None] * 24 - return usage + return cast(Sequence[Optional[int | float]], usage) def get_usage( self, room: FitnessRoom, date: datetime.date, num_samples: int, group_by: str, field: str - ) -> tuple[list[int | None], datetime.date, datetime.date]: + ) -> tuple[list[Optional[int | float]], datetime.date, datetime.date]: unit = 1 if group_by == "day" else 7 # skip by 1 or 7 days - usage_aggs = [(None, 0)] * 24 # (sum, count) for each hour + usage_aggs: list[tuple[Optional[float], int]] = [ + (None, 0) + ] * 24 # (sum, count) for each hour min_date = timezone.localtime().date() max_date = date - datetime.timedelta(days=unit * (num_samples - 1)) @@ -361,10 +369,10 @@ def get_usage( for (sum, count), val in zip(usage_aggs, usage) ] # update min and max date if any data was logged - if any(usage): + if any(u is not None for u in usage): min_date = min(min_date, curr) max_date = max(max_date, curr) - ret = [(sum / count) if count else None for (sum, count) in usage_aggs] + ret = [(sum / count if count and sum is not None else None) for (sum, count) in usage_aggs] return ret, min_date, max_date def get(self, request: Request, room_id: int) -> Response: @@ -397,6 +405,7 @@ def get(self, request: Request, room_id: int) -> Response: usage_per_hour, min_date, max_date = self.get_usage( room, date, num_samples, group_by, field ) + return Response( { "room_name": room.name, @@ -419,7 +428,7 @@ class FitnessPreferences(APIView): def get(self, request: Request) -> Response: - preferences = request.user.profile.fitness_preferences.all() + preferences = get_user(request).profile.fitness_preferences.all() # returns all ids in a person's preferences return Response({"rooms": preferences.values_list("id", flat=True)}) @@ -429,7 +438,7 @@ def post(self, request: Request) -> Response: if "rooms" not in request.data: return Response({"success": False, "error": "No rooms provided"}) - profile = request.user.profile + profile = get_user(request).profile ids = request.data["rooms"] @@ -454,14 +463,14 @@ class UniqueCounterView(APIView): permission_classes = [IsAuthenticated] def get(self, request: Request) -> Response: - query = dict() + query: dict[str, Any] = {} if "post_id" in request.query_params: query["post__id"] = request.query_params["post_id"] if "poll_id" in request.query_params: query["poll__id"] = request.query_params["poll_id"] if len(query) != 1: return Response({"detail": "require 1 id out of post_id or poll_id"}, status=400) - query["is_interaction"] = ( - request.query_params.get("is_interaction", "false").lower() == "true" + query["is_interaction"] = bool( + str(request.query_params.get("is_interaction", "false")).lower() == "true" ) return Response({"count": AnalyticsEvent.objects.filter(**query).count()}) diff --git a/backend/pennmobile/admin.py b/backend/pennmobile/admin.py index 2da5a734..45aa6711 100644 --- a/backend/pennmobile/admin.py +++ b/backend/pennmobile/admin.py @@ -1,21 +1,31 @@ # CUSTOM ADMIN SETTUP FOR PENN MOBILE -from typing import Any, Dict, Optional, Type, TypeAlias +from typing import Any, Dict, Optional, Type, TypeAlias, cast from django.contrib import admin, messages from django.contrib.admin.apps import AdminConfig from django.db.models import Model from django.http import HttpRequest +from django.template.response import TemplateResponse from django.urls import reverse from django.utils.html import format_html -ModelType: TypeAlias = Type[Model] AdminContext: TypeAlias = Dict[str, Any] MessageText: TypeAlias = str -def add_post_poll_message(request: HttpRequest, model: ModelType) -> None: - if (count := model.objects.filter(model.ACTION_REQUIRED_CONDITION).count()) > 0: +def add_post_poll_message(request: HttpRequest, model: Type[Model]) -> None: + from portal.models import Poll, Post + + model_obj: Poll | Post + if model == Poll: + model_obj = cast(Poll, model) + elif model == Post: + model_obj = cast(Post, model) + else: + raise ValueError(f"Invalid model: {model}") + + if (count := model_obj.objects.filter(model_obj.ACTION_REQUIRED_CONDITION).count()) > 0: link = reverse(f"admin:{model._meta.app_label}_{model._meta.model_name}_changelist") messages.info( request, @@ -30,7 +40,9 @@ def add_post_poll_message(request: HttpRequest, model: ModelType) -> None: class CustomAdminSite(admin.AdminSite): site_header = "Penn Mobile Backend Admin" - def index(self, request: HttpRequest, extra_context: Optional[AdminContext] = None) -> Any: + def index( + self, request: HttpRequest, extra_context: Optional[AdminContext] = None + ) -> TemplateResponse: from portal.models import Poll, Post add_post_poll_message(request, Post) @@ -43,4 +55,5 @@ class PennMobileAdminConfig(AdminConfig): default_site = "pennmobile.admin.CustomAdminSite" -admin.AdminSite = CustomAdminSite # anything else that overrides default admin should override ours +# anything else that overrides default admin should override ours +admin.site = CustomAdminSite() diff --git a/backend/pennmobile/celery.py b/backend/pennmobile/celery.py index 7b81bab0..18ddcd1d 100644 --- a/backend/pennmobile/celery.py +++ b/backend/pennmobile/celery.py @@ -1,4 +1,5 @@ import os +from typing import Any from celery import Celery @@ -21,5 +22,5 @@ @app.task(bind=True) -def debug_task(self) -> None: +def debug_task(self: Any) -> None: print(f"Request: {self.request!r}") diff --git a/backend/pennmobile/settings/base.py b/backend/pennmobile/settings/base.py index cf285a89..1cc083bd 100644 --- a/backend/pennmobile/settings/base.py +++ b/backend/pennmobile/settings/base.py @@ -89,7 +89,7 @@ # Database # https://docs.djangoproject.com/en/2.1/ref/settings/#databases DATABASES = { - "default": dj_database_url.config(default="sqlite:///" + os.path.join(BASE_DIR, "db.sqlite3")), + "default": dj_database_url.config(default="sqlite:///" + os.path.join(BASE_DIR, "db.sqlite3")) } DEFAULT_AUTO_FIELD = "django.db.models.AutoField" @@ -148,7 +148,7 @@ "rest_framework.authentication.SessionAuthentication", "rest_framework.authentication.BasicAuthentication", "accounts.authentication.PlatformAuthentication", - ], + ] } # Redis for Celery & Caching diff --git a/backend/pennmobile/settings/production.py b/backend/pennmobile/settings/production.py index 066012aa..87996116 100644 --- a/backend/pennmobile/settings/production.py +++ b/backend/pennmobile/settings/production.py @@ -17,7 +17,7 @@ ALLOWED_HOSTS = DOMAINS # Make sure SECRET_KEY is set to a secret in production -SECRET_KEY = os.environ.get("SECRET_KEY", None) +SECRET_KEY = os.environ.get("SECRET_KEY", "") # Sentry settings SENTRY_URL = os.environ.get("SENTRY_URL", "") diff --git a/backend/pennmobile/test_runner.py b/backend/pennmobile/test_runner.py index 55937401..cd4cd55d 100644 --- a/backend/pennmobile/test_runner.py +++ b/backend/pennmobile/test_runner.py @@ -1,31 +1,32 @@ +from typing import Any from unittest import mock from django.test.runner import DiscoverRunner from xmlrunner.extra.djangotestrunner import XMLTestRunner -def check_wharton(*args) -> bool: +def check_wharton(*args: Any) -> bool: return False class MockLabsAnalytics: - def __init__(self): + def __init__(self) -> None: pass - def submit(self, txn): + def submit(self, txn: Any) -> None: pass class MobileTestCIRunner(XMLTestRunner): @mock.patch("analytics.analytics.LabsAnalytics", MockLabsAnalytics) @mock.patch("gsr_booking.models.GroupMembership.check_wharton", check_wharton) - def run_tests(self, test_labels, **kwargs) -> None: + def run_tests(self, test_labels: list[str], **kwargs: Any) -> int: return super().run_tests(test_labels, **kwargs) class MobileTestLocalRunner(DiscoverRunner): @mock.patch("analytics.analytics.LabsAnalytics", MockLabsAnalytics) @mock.patch("gsr_booking.models.GroupMembership.check_wharton", check_wharton) - def run_tests(self, test_labels, **kwargs) -> None: + def run_tests(self, test_labels: list[str], **kwargs: Any) -> int: return super().run_tests(test_labels, **kwargs) diff --git a/backend/pennmobile/urls.py b/backend/pennmobile/urls.py index 5e94960e..1ea3b6d2 100644 --- a/backend/pennmobile/urls.py +++ b/backend/pennmobile/urls.py @@ -1,11 +1,15 @@ +from typing import List, Union + from django.conf import settings from django.contrib import admin -from django.urls import include, path +from django.urls import URLPattern, URLResolver, include, path from django.views.generic import TemplateView from rest_framework.schemas import get_schema_view -urlpatterns = [ +URLPatternList = List[Union[URLPattern, URLResolver]] + +urlpatterns: URLPatternList = [ path("gsr/", include("gsr_booking.urls")), path("portal/", include("portal.urls")), path("admin/", admin.site.urls), @@ -29,7 +33,7 @@ path("sublet/", include("sublet.urls")), ] -urlpatterns = [ +urlpatterns: URLPatternList = [ # type: ignore[no-redef] path("api/", include(urlpatterns)), path("", include((urlpatterns, "apex"))), ] @@ -37,4 +41,6 @@ if settings.DEBUG: import debug_toolbar - urlpatterns = [path("__debug__/", include(debug_toolbar.urls))] + urlpatterns + urlpatterns: URLPatternList = [ # type: ignore[no-redef] + path("__debug__/", include(debug_toolbar.urls)) + ] + urlpatterns diff --git a/backend/portal/admin.py b/backend/portal/admin.py index a92c248b..a39cfb05 100644 --- a/backend/portal/admin.py +++ b/backend/portal/admin.py @@ -1,51 +1,57 @@ +from typing import Any + from django.contrib import admin -from django.utils.html import escape, mark_safe +from django.db.models import QuerySet +from django.utils.html import escape +from django.utils.safestring import SafeString, mark_safe from portal.models import Content, Poll, PollOption, PollVote, Post, TargetPopulation class ContentAdmin(admin.ModelAdmin): @admin.action(description="Set status to Approved") - def action_approved(modeladmin, request, queryset): + def action_approved(modeladmin: Any, request: Any, queryset: QuerySet) -> None: queryset.update(status=Content.STATUS_APPROVED) @admin.action(description="Set status to Draft") - def action_draft(modeladmin, request, queryset): + def action_draft(modeladmin: Any, request: Any, queryset: QuerySet) -> None: queryset.update(status=Content.STATUS_DRAFT) @admin.action(description="Set status to Revision") - def action_revision(modeladmin, request, queryset): + def action_revision(modeladmin: Any, request: Any, queryset: QuerySet) -> None: queryset.update(status=Content.STATUS_REVISION) actions = [action_approved, action_draft, action_revision] - def get_queryset(self, request): + def get_queryset(self, request: Any) -> QuerySet: queryset = super().get_queryset(request) return queryset.annotate(ar=Content.ACTION_REQUIRED_CONDITION).order_by( "-ar", "-created_date" ) - def ar(self, obj): - return obj.ar + # Using any for the ar property since it comes from a queryset annotation + def ar(self, obj: Any) -> bool: + return bool(obj.ar) - ar.boolean = True + ar.boolean = True # type: ignore[attr-defined] class PostAdmin(ContentAdmin): - def image_tag(instance, height): + @staticmethod + def image_tag(instance: Post, height: int) -> SafeString: return mark_safe( f'' % escape(instance.image and instance.image.url) ) - def small_image(self, instance): + def small_image(self, instance: Post) -> SafeString: return PostAdmin.image_tag(instance, 100) - small_image.short_description = "Post Image" + small_image.short_description = "Post Image" # type: ignore[attr-defined] - def large_image(self, instance): + def large_image(self, instance: Post) -> SafeString: return PostAdmin.image_tag(instance, 300) - large_image.short_description = "Post Image" + large_image.short_description = "Post Image" # type: ignore[attr-defined] readonly_fields = ("large_image",) list_display = ( diff --git a/backend/portal/logic.py b/backend/portal/logic.py index 8234a246..bd6d546b 100644 --- a/backend/portal/logic.py +++ b/backend/portal/logic.py @@ -1,25 +1,15 @@ import json from collections import defaultdict -from typing import TYPE_CHECKING, Any +from typing import Any, Optional from accounts.ipc import authenticated_request -from django.contrib.auth import get_user_model from rest_framework.exceptions import PermissionDenied from portal.models import Poll, PollOption, PollVote, TargetPopulation +from utils.types import UserType -if TYPE_CHECKING: - from django.contrib.auth.models import AbstractUser - - UserType = AbstractUser -else: - UserType = Any - -User = get_user_model() - - -def get_user_info(user: "UserType") -> dict[str, Any]: +def get_user_info(user: UserType) -> dict[str, Any]: """Returns Platform user information""" response = authenticated_request(user, "GET", "https://platform.pennlabs.org/accounts/me/") if response.status_code == 403: @@ -27,7 +17,7 @@ def get_user_info(user: "UserType") -> dict[str, Any]: return json.loads(response.content) -def get_user_clubs(user: "UserType") -> list[dict[str, Any]]: +def get_user_clubs(user: UserType) -> list[dict[str, Any]]: """Returns list of clubs that user is a member of""" response = authenticated_request(user, "GET", "https://pennclubs.com/api/memberships/") if response.status_code == 403: @@ -36,7 +26,7 @@ def get_user_clubs(user: "UserType") -> list[dict[str, Any]]: return res_json -def get_club_info(user: "UserType", club_code: str) -> dict[str, Any]: +def get_club_info(user: UserType, club_code: str) -> dict[str, Any]: """Returns club information based on club code""" response = authenticated_request(user, "GET", f"https://pennclubs.com/api/clubs/{club_code}/") if response.status_code == 403: @@ -45,12 +35,12 @@ def get_club_info(user: "UserType", club_code: str) -> dict[str, Any]: return {"name": res_json["name"], "image": res_json["image_url"], "club_code": club_code} -def get_user_populations(user: "UserType") -> list[TargetPopulation]: +def get_user_populations(user: UserType) -> list[list[TargetPopulation]]: """Returns the target populations that the user belongs to""" user_info = get_user_info(user) - year = ( + year: list[TargetPopulation] = ( [ TargetPopulation.objects.get( kind=TargetPopulation.KIND_YEAR, population=user_info["student"]["graduation_year"] @@ -60,7 +50,7 @@ def get_user_populations(user: "UserType") -> list[TargetPopulation]: else [] ) - school = ( + school: list[TargetPopulation] = ( [ TargetPopulation.objects.get(kind=TargetPopulation.KIND_SCHOOL, population=x["name"]) for x in user_info["student"]["school"] @@ -69,7 +59,7 @@ def get_user_populations(user: "UserType") -> list[TargetPopulation]: else [] ) - major = ( + major: list[TargetPopulation] = ( [ TargetPopulation.objects.get(kind=TargetPopulation.KIND_MAJOR, population=x["name"]) for x in user_info["student"]["major"] @@ -78,7 +68,7 @@ def get_user_populations(user: "UserType") -> list[TargetPopulation]: else [] ) - degree = ( + degree: list[TargetPopulation] = ( [ TargetPopulation.objects.get( kind=TargetPopulation.KIND_DEGREE, population=x["degree_type"] @@ -92,29 +82,30 @@ def get_user_populations(user: "UserType") -> list[TargetPopulation]: return [year, school, major, degree] -def check_targets(obj: Poll, user: "UserType") -> bool: +def check_targets(obj: Poll, user: UserType) -> bool: """ Check if user aligns with target populations of poll or post """ - populations = get_user_populations(user) + population_groups = get_user_populations(user) - year = set(obj.target_populations.filter(kind=TargetPopulation.KIND_YEAR)) - school = set(obj.target_populations.filter(kind=TargetPopulation.KIND_SCHOOL)) - major = set(obj.target_populations.filter(kind=TargetPopulation.KIND_MAJOR)) - degree = set(obj.target_populations.filter(kind=TargetPopulation.KIND_DEGREE)) + year_targets = set(obj.target_populations.filter(kind=TargetPopulation.KIND_YEAR)) + school_targets = set(obj.target_populations.filter(kind=TargetPopulation.KIND_SCHOOL)) + major_targets = set(obj.target_populations.filter(kind=TargetPopulation.KIND_MAJOR)) + degree_targets = set(obj.target_populations.filter(kind=TargetPopulation.KIND_DEGREE)) - return ( - set(populations[0]).issubset(year) - and set(populations[1]).issubset(school) - and set(populations[2]).issubset(major) - and set(populations[3]).issubset(degree) + return all( + set(group).issubset(targets) + for group, targets in zip( + population_groups, [year_targets, school_targets, major_targets, degree_targets] + ) ) -def get_demographic_breakdown(poll_id: int) -> list[dict[str, Any]]: +def get_demographic_breakdown(poll_id: Optional[int] = None) -> list[dict[str, Any]]: """Collects Poll statistics on school and graduation year demographics""" - + if poll_id is None: + raise ValueError("poll_id is required") # passing in id is necessary because # poll info is already serialized poll = Poll.objects.get(id=poll_id) diff --git a/backend/portal/management/commands/load_target_populations.py b/backend/portal/management/commands/load_target_populations.py index 82ecf247..6244e73a 100644 --- a/backend/portal/management/commands/load_target_populations.py +++ b/backend/portal/management/commands/load_target_populations.py @@ -1,3 +1,6 @@ +from argparse import ArgumentParser +from typing import Any, Optional + import requests from django.core.management.base import BaseCommand from django.utils import timezone @@ -6,7 +9,7 @@ class Command(BaseCommand): - def add_arguments(self, parser): + def add_arguments(self, parser: ArgumentParser) -> None: parser.add_argument( "--years", type=str, @@ -14,7 +17,7 @@ def add_arguments(self, parser): "This is only used for testing currently.", ) - def handle(self, *args, **kwargs): + def handle(self, *args: Any, **kwargs: Any) -> None: # loads majors, years, schools, and degrees onto TargetPopulations # runs get_or_create to ensure no duplicates majors = requests.get("https://platform.pennlabs.org/accounts/majors/").json() @@ -36,10 +39,10 @@ def handle(self, *args, **kwargs): TargetPopulation.objects.get_or_create(kind=TargetPopulation.KIND_YEAR, population=year) self.stdout.write("Uploaded Target Populations!") - def get_degrees(self): + def get_degrees(self) -> list[str]: return ["BACHELORS", "MASTERS", "PHD", "PROFESSIONAL"] - def get_years(self, years): + def get_years(self, years: Optional[str] = None) -> list[int]: # creates new class year in August in preparation for upcoming school year if years is None: return ( diff --git a/backend/portal/management/commands/polls_populate.py b/backend/portal/management/commands/polls_populate.py index 1414ab37..e5939f87 100644 --- a/backend/portal/management/commands/polls_populate.py +++ b/backend/portal/management/commands/polls_populate.py @@ -1,19 +1,29 @@ import datetime +from typing import Any -from django.contrib.auth import get_user_model from django.core.management import call_command from django.core.management.base import BaseCommand from django.utils import timezone from portal.models import Poll, PollOption, PollVote, TargetPopulation from user.models import Profile - - -User = get_user_model() +from utils.types import DjangoUserModel, UserType class Command(BaseCommand): - def handle(self, *args, **kwargs): + def _create_user( + self, username: str, email: str, password: str, graduation_date: datetime.date + ) -> UserType: + """Helper to create a user with profile""" + if not DjangoUserModel.objects.filter(username=username).exists(): + user = DjangoUserModel.objects.create_user(username, email, password) + profile = Profile.objects.get(user=user) + setattr(profile, "expected_graduation", graduation_date) + profile.save() + return user + return DjangoUserModel.objects.get(username=username) + + def handle(self, *args: Any, **kwargs: Any) -> None: # Define graduation years df_2022 = datetime.date(2022, 5, 15) @@ -22,55 +32,12 @@ def handle(self, *args, **kwargs): df_2025 = datetime.date(2025, 5, 17) # Create users and set graduation years - if not User.objects.filter(username="user1").first(): - user1 = User.objects.create_user("user1", "user@seas.upenn.edu", "user") - user1_profile = Profile.objects.get(user=user1) - user1_profile.expected_graduation = df_2022 - user1_profile.save() - else: - user1 = User.objects.get(username="user1") - - if not User.objects.filter(username="user2").first(): - user2 = User.objects.create_user("user2", "user2@seas.upenn.edu", "user2") - user2_profile = Profile.objects.get(user=user2) - user2_profile.expected_graduation = df_2023 - user2_profile.save() - else: - user2 = User.objects.get(username="user2") - - if not User.objects.filter(username="user3").first(): - user3 = User.objects.create_user("user3", "user3@seas.upenn.edu", "user3") - user3_profile = Profile.objects.get(user=user3) - user3_profile.expected_graduation = df_2024 - user3_profile.save() - else: - user3 = User.objects.get(username="user3") - - if not User.objects.filter(username="user_cas").first(): - user_cas = User.objects.create_user("user_cas", "user@sas.upenn.edu", "user_cas") - user_cas_profile = Profile.objects.get(user=user_cas) - user_cas_profile.expected_graduation = df_2025 - user_cas_profile.save() - else: - user_cas = User.objects.get(username="user_cas") - - if not User.objects.filter(username="user_wh").first(): - user_wh = User.objects.create_user("user_wh", "user@wharton.upenn.edu", "user_wh") - user_wh_profile = Profile.objects.get(user=user_wh) - user_wh_profile.expected_graduation = df_2024 - user_wh_profile.save() - else: - user_wh = User.objects.get(username="user_wh") - - if not User.objects.filter(username="user_nursing").first(): - user_nursing = User.objects.create_user( - "user_nursing", "user@nursing.upenn.edu", "user_nursing" - ) - user_nursing_profile = Profile.objects.get(user=user_nursing) - user_nursing_profile.expected_graduation = df_2023 - user_nursing_profile.save() - else: - user_nursing = User.objects.get(username="user_nursing") + self._create_user("user1", "user@seas.upenn.edu", "user", df_2022) + self._create_user("user2", "user2@seas.upenn.edu", "user2", df_2023) + self._create_user("user3", "user3@seas.upenn.edu", "user3", df_2024) + self._create_user("user_cas", "user@sas.upenn.edu", "user_cas", df_2025) + self._create_user("user_wh", "user@wharton.upenn.edu", "user_wh", df_2024) + self._create_user("user_nursing", "user@nursing.upenn.edu", "user_nursing", df_2023) # Create target populations call_command("load_target_populations", "--years", "2022, 2023, 2024, 2025") diff --git a/backend/portal/migrations/0001_initial.py b/backend/portal/migrations/0001_initial.py index c7ed66f3..ec955f2c 100644 --- a/backend/portal/migrations/0001_initial.py +++ b/backend/portal/migrations/0001_initial.py @@ -10,9 +10,7 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] + dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] operations = [ migrations.CreateModel( diff --git a/backend/portal/migrations/0002_auto_20211003_2225.py b/backend/portal/migrations/0002_auto_20211003_2225.py index 06a6a1b6..33fa66d9 100644 --- a/backend/portal/migrations/0002_auto_20211003_2225.py +++ b/backend/portal/migrations/0002_auto_20211003_2225.py @@ -6,24 +6,17 @@ class Migration(migrations.Migration): - dependencies = [ - ("portal", "0001_initial"), - ] + dependencies = [("portal", "0001_initial")] operations = [ - migrations.RemoveField( - model_name="poll", - name="image_url", - ), + migrations.RemoveField(model_name="poll", name="image_url"), migrations.AddField( model_name="poll", name="start_date", field=models.DateTimeField(default=django.utils.timezone.now), ), migrations.AddField( - model_name="polloption", - name="vote_count", - field=models.IntegerField(default=0), + model_name="polloption", name="vote_count", field=models.IntegerField(default=0) ), migrations.AddField( model_name="targetpopulation", diff --git a/backend/portal/migrations/0003_alter_targetpopulation_kind.py b/backend/portal/migrations/0003_alter_targetpopulation_kind.py index 6d89a215..2868d863 100644 --- a/backend/portal/migrations/0003_alter_targetpopulation_kind.py +++ b/backend/portal/migrations/0003_alter_targetpopulation_kind.py @@ -5,9 +5,7 @@ class Migration(migrations.Migration): - dependencies = [ - ("portal", "0002_auto_20211003_2225"), - ] + dependencies = [("portal", "0002_auto_20211003_2225")] operations = [ migrations.AlterField( @@ -23,5 +21,5 @@ class Migration(migrations.Migration): default="SCHOOL", max_length=10, ), - ), + ) ] diff --git a/backend/portal/migrations/0004_post.py b/backend/portal/migrations/0004_post.py index c80fd9dd..cdeb467a 100644 --- a/backend/portal/migrations/0004_post.py +++ b/backend/portal/migrations/0004_post.py @@ -45,5 +45,5 @@ class Migration(migrations.Migration): ), ), ], - ), + ) ] diff --git a/backend/portal/migrations/0005_auto_20211231_1558.py b/backend/portal/migrations/0005_auto_20211231_1558.py index 2f35eba8..6df6356a 100644 --- a/backend/portal/migrations/0005_auto_20211231_1558.py +++ b/backend/portal/migrations/0005_auto_20211231_1558.py @@ -6,36 +6,16 @@ class Migration(migrations.Migration): - dependencies = [ - ("portal", "0004_post"), - ] + dependencies = [("portal", "0004_post")] operations = [ - migrations.RenameField( - model_name="poll", - old_name="user_comment", - new_name="club_comment", - ), - migrations.RemoveField( - model_name="poll", - name="approved", - ), - migrations.RemoveField( - model_name="poll", - name="source", - ), - migrations.RemoveField( - model_name="poll", - name="user", - ), - migrations.RemoveField( - model_name="pollvote", - name="user", - ), + migrations.RenameField(model_name="poll", old_name="user_comment", new_name="club_comment"), + migrations.RemoveField(model_name="poll", name="approved"), + migrations.RemoveField(model_name="poll", name="source"), + migrations.RemoveField(model_name="poll", name="user"), + migrations.RemoveField(model_name="pollvote", name="user"), migrations.AddField( - model_name="poll", - name="club_code", - field=models.CharField(blank=True, max_length=255), + model_name="poll", name="club_code", field=models.CharField(blank=True, max_length=255) ), migrations.AddField( model_name="poll", diff --git a/backend/portal/migrations/0006_auto_20220112_1529.py b/backend/portal/migrations/0006_auto_20220112_1529.py index 3dc42228..44bfd28c 100644 --- a/backend/portal/migrations/0006_auto_20220112_1529.py +++ b/backend/portal/migrations/0006_auto_20220112_1529.py @@ -5,36 +5,15 @@ class Migration(migrations.Migration): - dependencies = [ - ("portal", "0005_auto_20211231_1558"), - ] + dependencies = [("portal", "0005_auto_20211231_1558")] operations = [ - migrations.RenameField( - model_name="post", - old_name="user_comment", - new_name="club_comment", - ), - migrations.RenameField( - model_name="post", - old_name="created_at", - new_name="created_date", - ), - migrations.RemoveField( - model_name="post", - name="approved", - ), - migrations.RemoveField( - model_name="post", - name="source", - ), - migrations.RemoveField( - model_name="post", - name="user", - ), + migrations.RenameField(model_name="post", old_name="user_comment", new_name="club_comment"), + migrations.RenameField(model_name="post", old_name="created_at", new_name="created_date"), + migrations.RemoveField(model_name="post", name="approved"), + migrations.RemoveField(model_name="post", name="source"), + migrations.RemoveField(model_name="post", name="user"), migrations.AddField( - model_name="post", - name="club_code", - field=models.CharField(blank=True, max_length=255), + model_name="post", name="club_code", field=models.CharField(blank=True, max_length=255) ), ] diff --git a/backend/portal/migrations/0007_post_status.py b/backend/portal/migrations/0007_post_status.py index ae126767..7cfba2bf 100644 --- a/backend/portal/migrations/0007_post_status.py +++ b/backend/portal/migrations/0007_post_status.py @@ -5,9 +5,7 @@ class Migration(migrations.Migration): - dependencies = [ - ("portal", "0006_auto_20220112_1529"), - ] + dependencies = [("portal", "0006_auto_20220112_1529")] operations = [ migrations.AddField( @@ -18,5 +16,5 @@ class Migration(migrations.Migration): default="DRAFT", max_length=30, ), - ), + ) ] diff --git a/backend/portal/migrations/0008_alter_post_image_url.py b/backend/portal/migrations/0008_alter_post_image_url.py index 2249e1d0..92ba5a3a 100644 --- a/backend/portal/migrations/0008_alter_post_image_url.py +++ b/backend/portal/migrations/0008_alter_post_image_url.py @@ -5,14 +5,12 @@ class Migration(migrations.Migration): - dependencies = [ - ("portal", "0007_post_status"), - ] + dependencies = [("portal", "0007_post_status")] operations = [ migrations.AlterField( model_name="post", name="image_url", field=models.ImageField(blank=True, null=True, upload_to="portal/images"), - ), + ) ] diff --git a/backend/portal/migrations/0009_rename_image_url_post_image.py b/backend/portal/migrations/0009_rename_image_url_post_image.py index 64028b4f..cbfca6f1 100644 --- a/backend/portal/migrations/0009_rename_image_url_post_image.py +++ b/backend/portal/migrations/0009_rename_image_url_post_image.py @@ -5,14 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ - ("portal", "0008_alter_post_image_url"), - ] + dependencies = [("portal", "0008_alter_post_image_url")] - operations = [ - migrations.RenameField( - model_name="post", - old_name="image_url", - new_name="image", - ), - ] + operations = [migrations.RenameField(model_name="post", old_name="image_url", new_name="image")] diff --git a/backend/portal/migrations/0010_remove_post_image.py b/backend/portal/migrations/0010_remove_post_image.py index ede76232..22759b5c 100644 --- a/backend/portal/migrations/0010_remove_post_image.py +++ b/backend/portal/migrations/0010_remove_post_image.py @@ -5,13 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ - ("portal", "0009_rename_image_url_post_image"), - ] + dependencies = [("portal", "0009_rename_image_url_post_image")] - operations = [ - migrations.RemoveField( - model_name="post", - name="image", - ), - ] + operations = [migrations.RemoveField(model_name="post", name="image")] diff --git a/backend/portal/migrations/0011_post_image.py b/backend/portal/migrations/0011_post_image.py index b7a03d12..b2d9b737 100644 --- a/backend/portal/migrations/0011_post_image.py +++ b/backend/portal/migrations/0011_post_image.py @@ -5,14 +5,12 @@ class Migration(migrations.Migration): - dependencies = [ - ("portal", "0010_remove_post_image"), - ] + dependencies = [("portal", "0010_remove_post_image")] operations = [ migrations.AddField( model_name="post", name="image", field=models.ImageField(blank=True, null=True, upload_to="portal/images"), - ), + ) ] diff --git a/backend/portal/migrations/0012_remove_post_image.py b/backend/portal/migrations/0012_remove_post_image.py index 2ae2a531..32a1013b 100644 --- a/backend/portal/migrations/0012_remove_post_image.py +++ b/backend/portal/migrations/0012_remove_post_image.py @@ -5,13 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ - ("portal", "0011_post_image"), - ] + dependencies = [("portal", "0011_post_image")] - operations = [ - migrations.RemoveField( - model_name="post", - name="image", - ), - ] + operations = [migrations.RemoveField(model_name="post", name="image")] diff --git a/backend/portal/migrations/0013_post_image.py b/backend/portal/migrations/0013_post_image.py index 44a7ac6c..cf67dfee 100644 --- a/backend/portal/migrations/0013_post_image.py +++ b/backend/portal/migrations/0013_post_image.py @@ -5,14 +5,12 @@ class Migration(migrations.Migration): - dependencies = [ - ("portal", "0012_remove_post_image"), - ] + dependencies = [("portal", "0012_remove_post_image")] operations = [ migrations.AddField( model_name="post", name="image", field=models.ImageField(blank=True, null=True, upload_to="portal/images"), - ), + ) ] diff --git a/backend/portal/migrations/0014_alter_post_post_url.py b/backend/portal/migrations/0014_alter_post_post_url.py index b2690d59..bd0df256 100644 --- a/backend/portal/migrations/0014_alter_post_post_url.py +++ b/backend/portal/migrations/0014_alter_post_post_url.py @@ -5,14 +5,12 @@ class Migration(migrations.Migration): - dependencies = [ - ("portal", "0013_post_image"), - ] + dependencies = [("portal", "0013_post_image")] operations = [ migrations.AlterField( model_name="post", name="post_url", field=models.CharField(blank=True, max_length=255, null=True), - ), + ) ] diff --git a/backend/portal/migrations/0015_auto_20240226_2236.py b/backend/portal/migrations/0015_auto_20240226_2236.py index 7a9c7108..722d7083 100644 --- a/backend/portal/migrations/0015_auto_20240226_2236.py +++ b/backend/portal/migrations/0015_auto_20240226_2236.py @@ -5,24 +5,14 @@ class Migration(migrations.Migration): - dependencies = [ - ("portal", "0014_alter_post_post_url"), - ] + dependencies = [("portal", "0014_alter_post_post_url")] operations = [ migrations.AddField( - model_name="poll", - name="priority", - field=models.IntegerField(default=0), + model_name="poll", name="priority", field=models.IntegerField(default=0) ), migrations.AddField( - model_name="post", - name="priority", - field=models.IntegerField(default=0), - ), - migrations.AlterField( - model_name="poll", - name="expire_date", - field=models.DateTimeField(), + model_name="post", name="priority", field=models.IntegerField(default=0) ), + migrations.AlterField(model_name="poll", name="expire_date", field=models.DateTimeField()), ] diff --git a/backend/portal/models.py b/backend/portal/models.py index b15aeee1..6e11f0a9 100644 --- a/backend/portal/models.py +++ b/backend/portal/models.py @@ -1,3 +1,5 @@ +from typing import Any + from django.contrib.auth import get_user_model from django.db import models from django.db.models import Q @@ -6,9 +8,6 @@ from utils.email import get_backend_manager_emails, send_automated_email -User = get_user_model() - - class TargetPopulation(models.Model): KIND_SCHOOL = "SCHOOL" KIND_YEAR = "YEAR" @@ -21,10 +20,13 @@ class TargetPopulation(models.Model): (KIND_DEGREE, "Degree"), ) - kind = models.CharField(max_length=10, choices=KIND_OPTIONS, default=KIND_SCHOOL) - population = models.CharField(max_length=255) + id: int + kind: models.CharField = models.CharField( + max_length=10, choices=KIND_OPTIONS, default=KIND_SCHOOL + ) + population: models.CharField = models.CharField(max_length=255) - def __str__(self): + def __str__(self) -> str: return self.population @@ -41,34 +43,46 @@ class Content(models.Model): ACTION_REQUIRED_CONDITION = Q(expire_date__gt=timezone.now()) & Q(status=STATUS_DRAFT) - club_code = models.CharField(max_length=255, blank=True) - created_date = models.DateTimeField(default=timezone.now) - start_date = models.DateTimeField(default=timezone.now) - expire_date = models.DateTimeField() - status = models.CharField(max_length=30, choices=STATUS_OPTIONS, default=STATUS_DRAFT) - club_comment = models.CharField(max_length=255, null=True, blank=True) - admin_comment = models.CharField(max_length=255, null=True, blank=True) - target_populations = models.ManyToManyField(TargetPopulation, blank=True) - priority = models.IntegerField(default=0) - creator = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) + id: int + club_code: models.CharField = models.CharField(max_length=255, blank=True) + created_date: models.DateTimeField = models.DateTimeField(default=timezone.now) + start_date: models.DateTimeField = models.DateTimeField(default=timezone.now) + expire_date: models.DateTimeField = models.DateTimeField() + status: models.CharField = models.CharField( + max_length=30, choices=STATUS_OPTIONS, default=STATUS_DRAFT + ) + club_comment: models.CharField = models.CharField(max_length=255, null=True, blank=True) + admin_comment: models.CharField = models.CharField(max_length=255, null=True, blank=True) + target_populations: models.ManyToManyField = models.ManyToManyField( + TargetPopulation, blank=True + ) + priority: models.IntegerField = models.IntegerField(default=0) + creator: models.ForeignKey = models.ForeignKey( + get_user_model(), on_delete=models.SET_NULL, null=True, blank=True + ) class Meta: abstract = True - def _get_email_subject(self): - return f"[Portal] {self.__class__._meta.model_name.capitalize()} #{self.id}" + def _get_email_subject(self) -> str: + model_name = ( + self.__class__._meta.model_name.capitalize() + if self.__class__._meta.model_name is not None + else "" + ) + return f"[Portal] {model_name} #{self.id}" - def _on_create(self): + def _on_create(self) -> None: send_automated_email.delay_on_commit( self._get_email_subject(), get_backend_manager_emails(), ( - f"A new {self.__class__._meta.model_name} for {self.club_code}" + f"A new {self.__class__._meta.model_name} for {self.club_code} " f"has been created by {self.creator}." ), ) - def _on_status_change(self): + def _on_status_change(self) -> None: if email := getattr(self.creator, "email", None): send_automated_email.delay_on_commit( self._get_email_subject(), @@ -82,7 +96,7 @@ def _on_status_change(self): ), ) - def save(self, *args, **kwargs): + def save(self, *args: Any, **kwargs: Any) -> None: prev = self.__class__.objects.filter(id=self.id).first() super().save(*args, **kwargs) if prev is None: @@ -93,35 +107,39 @@ def save(self, *args, **kwargs): class Poll(Content): - question = models.CharField(max_length=255) - multiselect = models.BooleanField(default=False) + question: models.CharField = models.CharField(max_length=255) + multiselect: models.BooleanField = models.BooleanField(default=False) - def __str__(self): + def __str__(self) -> str: return self.question class PollOption(models.Model): - poll = models.ForeignKey(Poll, on_delete=models.CASCADE) - choice = models.CharField(max_length=255) - vote_count = models.IntegerField(default=0) + id: int + poll: models.ForeignKey = models.ForeignKey(Poll, on_delete=models.CASCADE) + choice: models.CharField = models.CharField(max_length=255) + vote_count: models.IntegerField = models.IntegerField(default=0) - def __str__(self): + def __str__(self) -> str: return f"{self.poll.id} - Option - {self.choice}" class PollVote(models.Model): - id_hash = models.CharField(max_length=255, blank=True) - poll = models.ForeignKey(Poll, on_delete=models.CASCADE) - poll_options = models.ManyToManyField(PollOption) - created_date = models.DateTimeField(default=timezone.now) - target_populations = models.ManyToManyField(TargetPopulation, blank=True) + id: int + id_hash: models.CharField = models.CharField(max_length=255, blank=True) + poll: models.ForeignKey = models.ForeignKey(Poll, on_delete=models.CASCADE) + poll_options: models.ManyToManyField = models.ManyToManyField(PollOption) + created_date: models.DateTimeField = models.DateTimeField(default=timezone.now) + target_populations: models.ManyToManyField = models.ManyToManyField( + TargetPopulation, blank=True + ) class Post(Content): - title = models.CharField(max_length=255) - subtitle = models.CharField(max_length=255) - post_url = models.CharField(max_length=255, null=True, blank=True) - image = models.ImageField(upload_to="portal/images", null=True, blank=True) + title: models.CharField = models.CharField(max_length=255) + subtitle: models.CharField = models.CharField(max_length=255) + post_url: models.CharField = models.CharField(max_length=255, null=True, blank=True) + image: models.ImageField = models.ImageField(upload_to="portal/images", null=True, blank=True) - def __str__(self): + def __str__(self) -> str: return self.title diff --git a/backend/portal/permissions.py b/backend/portal/permissions.py index adc319ba..c8b09364 100644 --- a/backend/portal/permissions.py +++ b/backend/portal/permissions.py @@ -1,7 +1,11 @@ +from typing import Any, cast + from rest_framework import permissions +from rest_framework.request import Request from portal.logic import get_user_clubs -from portal.models import Poll +from portal.models import Poll, PollOption +from utils.types import get_auth_user class IsSuperUser(permissions.BasePermission): @@ -9,66 +13,79 @@ class IsSuperUser(permissions.BasePermission): Grants permission if the current user is a superuser. """ - def has_object_permission(self, request, view, obj): - return request.user.is_superuser + def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool: + return get_auth_user(request).is_superuser - def has_permission(self, request, view): - return request.user.is_superuser + def has_permission(self, request: Request, view: Any) -> bool: + return get_auth_user(request).is_superuser class PollOwnerPermission(permissions.BasePermission): """Permission that checks authentication and only permits owner to update/destroy objects""" - def has_object_permission(self, request, view, obj): + def _get_club_code(self, obj: Any) -> str: + """Helper to get club_code from either Poll or PollOption object""" + if isinstance(obj, Poll): + return obj.club_code + elif isinstance(obj, PollOption): + poll = cast(Poll, obj.poll) + return poll.club_code + raise ValueError(f"Unexpected object type: {type(obj)}") + + def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool: # only creator can edit + user = get_auth_user(request) if view.action in ["partial_update", "update", "destroy"]: - return obj.club_code in [x["club"]["code"] for x in get_user_clubs(request.user)] - return request.user.is_authenticated + club_code = self._get_club_code(obj) + return club_code in [x["club"]["code"] for x in get_user_clubs(user)] + return user.is_authenticated - def has_permission(self, request, view): - return request.user.is_authenticated + def has_permission(self, request: Request, view: Any) -> bool: + return get_auth_user(request).is_authenticated class OptionOwnerPermission(permissions.BasePermission): """Permission that checks authentication and only permits owner of Poll to update corresponding Option objects""" - def has_object_permission(self, request, view, obj): + def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool: # only creator can edit + user = get_auth_user(request) if view.action in ["partial_update", "update", "destroy"]: - return obj.poll.club_code in [x["club"]["code"] for x in get_user_clubs(request.user)] + return obj.poll.club_code in [x["club"]["code"] for x in get_user_clubs(user)] return True - def has_permission(self, request, view): + def has_permission(self, request: Request, view: Any) -> bool: # only creator of poll can create poll option + user = get_auth_user(request) if view.action == "create" and request.data: poll = Poll.objects.get(id=request.data["poll"]) - return poll.club_code in [x["club"]["code"] for x in get_user_clubs(request.user)] - return request.user.is_authenticated + return poll.club_code in [x["club"]["code"] for x in get_user_clubs(user)] + return user.is_authenticated class TimeSeriesPermission(permissions.BasePermission): """Permission that checks for Time Series access (only creator of Poll and admins)""" - def has_permission(self, request, view): - poll = Poll.objects.filter(id=view.kwargs["poll_id"]) + def has_permission(self, request: Request, view: Any) -> bool: + poll = Poll.objects.filter(id=view.kwargs["poll_id"]).first() + user = get_auth_user(request) # checks if poll exists - if poll.exists(): + if poll is not None: # only poll creator and admin can access - return poll.first().club_code in [ - x["club"]["code"] for x in get_user_clubs(request.user) - ] + return poll.club_code in [x["club"]["code"] for x in get_user_clubs(user)] return False class PostOwnerPermission(permissions.BasePermission): """checks authentication and only permits owner to update/destroy posts""" - def has_object_permission(self, request, view, obj): + def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool: # only creator can edit + user = get_auth_user(request) if view.action in ["partial_update", "update", "destroy"]: - return obj.club_code in [x["club"]["code"] for x in get_user_clubs(request.user)] + return obj.club_code in [x["club"]["code"] for x in get_user_clubs(user)] return True - def has_permission(self, request, view): + def has_permission(self, request: Request, view: Any) -> bool: return request.user.is_authenticated diff --git a/backend/portal/serializers.py b/backend/portal/serializers.py index d9a65e44..b384d90e 100644 --- a/backend/portal/serializers.py +++ b/backend/portal/serializers.py @@ -1,5 +1,6 @@ -from typing import Any, Dict, TypeAlias +from typing import Any, ClassVar, Type, cast +from django.db.models import Model from django.http.request import QueryDict from rest_framework import serializers @@ -7,10 +8,6 @@ from portal.models import Content, Poll, PollOption, PollVote, Post, TargetPopulation -ClubCode: TypeAlias = str -ValidationData: TypeAlias = Dict[str, Any] - - class TargetPopulationSerializer(serializers.ModelSerializer): class Meta: model = TargetPopulation @@ -19,7 +16,8 @@ class Meta: class ContentSerializer(serializers.ModelSerializer): class Meta: - fields = ( + model: ClassVar[Type[Model]] + fields: tuple[str, ...] = ( "id", "club_code", "created_date", @@ -30,10 +28,10 @@ class Meta: "status", "target_populations", ) - read_only_fields = ("id", "created_date") + read_only_fields: tuple[str, ...] = ("id", "created_date") abstract = True - def _auto_add_target_population(self, validated_data: ValidationData) -> None: + def _auto_add_target_population(self, validated_data: dict[str, Any]) -> None: # auto add all target populations of a kind if not specified if target_populations := validated_data.get("target_populations"): auto_add_kind = [ @@ -47,15 +45,19 @@ def _auto_add_target_population(self, validated_data: ValidationData) -> None: else: validated_data["target_populations"] = list(TargetPopulation.objects.all()) - def create(self, validated_data: ValidationData) -> Poll: - club_code: ClubCode = validated_data["club_code"] + def create(self, validated_data: dict[str, Any]) -> Poll: + club_code: str = validated_data["club_code"] user = self.context["request"].user # ensures user is part of club if not any([x["club"]["code"] == club_code for x in get_user_clubs(user)]): + model_name = ( + self.Meta.model._meta.model_name.capitalize() + if self.Meta.model._meta.model_name is not None + else "content" + ) raise serializers.ValidationError( detail={ - "detail": "You do not have access to create a " - + f"{self.Meta.model._meta.model_name.capitalize()} under this club." + "detail": f"You do not have access to create a {model_name} under this club." } ) @@ -69,7 +71,7 @@ def create(self, validated_data: ValidationData) -> Poll: return super().create(validated_data) - def update(self, instance: Content, validated_data: ValidationData) -> Content: + def update(self, instance: Content, validated_data: dict[str, Any]) -> Content: # if Content is updated, then approve should be false if not self.context["request"].user.is_superuser: validated_data["status"] = Content.STATUS_DRAFT @@ -82,25 +84,16 @@ def update(self, instance: Content, validated_data: ValidationData) -> Content: class PollSerializer(ContentSerializer): class Meta(ContentSerializer.Meta): model = Poll - fields = ( - *ContentSerializer.Meta.fields, - "question", - "multiselect", - ) + fields: tuple[str, ...] = (*ContentSerializer.Meta.fields, "question", "multiselect") class PollOptionSerializer(serializers.ModelSerializer): class Meta: model = PollOption - fields = ( - "id", - "poll", - "choice", - "vote_count", - ) - read_only_fields = ("id", "vote_count") + fields: tuple[str, ...] = ("id", "poll", "choice", "vote_count") + read_only_fields: tuple[str, ...] = ("id", "vote_count") - def create(self, validated_data: ValidationData) -> PollOption: + def create(self, validated_data: dict[str, Any]) -> PollOption: poll_options_count = PollOption.objects.filter(poll=validated_data["poll"]).count() if poll_options_count >= 5: raise serializers.ValidationError( @@ -108,10 +101,11 @@ def create(self, validated_data: ValidationData) -> PollOption: ) return super().create(validated_data) - def update(self, instance, validated_data): + def update(self, instance: PollOption, validated_data: dict[str, Any]) -> PollOption: # if Poll Option is updated, then corresponding Poll approval should be false - instance.poll.status = Poll.STATUS_DRAFT - instance.poll.save() + poll = cast(Poll, instance.poll) + poll.status = Poll.STATUS_DRAFT + poll.save() return super().update(instance, validated_data) @@ -122,7 +116,7 @@ class RetrievePollSerializer(serializers.ModelSerializer): class Meta: model = Poll - fields = ( + fields: tuple[str, ...] = ( "id", "club_code", "question", @@ -140,13 +134,10 @@ class Meta: class PollVoteSerializer(serializers.ModelSerializer): class Meta: model = PollVote - fields = ("id", "id_hash", "poll_options", "created_date") - read_only_fields = ( - "id", - "created_date", - ) + fields: tuple[str, ...] = ("id", "id_hash", "poll_options", "created_date") + read_only_fields: tuple[str, ...] = ("id", "created_date") - def create(self, validated_data: ValidationData) -> PollVote: + def create(self, validated_data: dict[str, Any]) -> PollVote: options = validated_data["poll_options"] id_hash = validated_data["id_hash"] @@ -201,11 +192,8 @@ class RetrievePollVoteSerializer(serializers.ModelSerializer): class Meta: model = PollVote - fields = ("id", "id_hash", "poll", "poll_options", "created_date") - read_only_fields = ( - "id", - "created_date", - ) + fields: tuple[str, ...] = ("id", "id_hash", "poll", "poll_options", "created_date") + read_only_fields: tuple[str, ...] = ("id", "created_date") class PostSerializer(ContentSerializer): @@ -229,7 +217,7 @@ def get_image_url(self, obj: Post) -> str | None: class Meta(ContentSerializer.Meta): model = Post - fields = ( + fields: tuple[str, ...] = ( *ContentSerializer.Meta.fields, "title", "subtitle", @@ -240,12 +228,13 @@ class Meta(ContentSerializer.Meta): def is_valid(self, *args: Any, **kwargs: Any) -> bool: if isinstance(self.initial_data, QueryDict): - self.initial_data = self.initial_data.dict() - self.initial_data["target_populations"] = list( - ( - map(int, self.initial_data["target_populations"].split(",")) - if "target_populations" in self.initial_data + data = self.initial_data.dict() + target_populations = data.get("target_populations", "") + if isinstance(target_populations, str): + data["target_populations"] = ( + list(map(int, target_populations.split(","))) + if target_populations.strip() else [] - ), - ) + ) + self.initial_data = data return super().is_valid(*args, **kwargs) diff --git a/backend/portal/views.py b/backend/portal/views.py index 61ac2413..b87b058c 100644 --- a/backend/portal/views.py +++ b/backend/portal/views.py @@ -1,6 +1,5 @@ -from typing import Any, Dict, List, TypeAlias +from typing import Any, List, Optional, TypeAlias -from django.contrib.auth import get_user_model from django.db.models import Count, Manager, Q, QuerySet from django.db.models.functions import Trunc from django.utils import timezone @@ -36,17 +35,13 @@ RetrievePollVoteSerializer, TargetPopulationSerializer, ) +from utils.types import AuthRequest, get_auth_user PollQuerySet: TypeAlias = QuerySet[Poll, Manager[Poll]] PostQuerySet: TypeAlias = QuerySet[Post, Manager[Post]] PollVoteQuerySet: TypeAlias = QuerySet[PollVote, Manager[PollVote]] -ClubData: TypeAlias = List[Dict[str, Any]] PollOptionQuerySet: TypeAlias = QuerySet[PollOption, Manager[PollOption]] -TimeSeriesData: TypeAlias = Dict[str, Any] -VoteStatistics: TypeAlias = Dict[str, Any] - -User = get_user_model() class UserInfo(APIView): @@ -54,7 +49,7 @@ class UserInfo(APIView): permission_classes = [IsAuthenticated] - def get(self, request: Request) -> Response: + def get(self, request: AuthRequest) -> Response: return Response({"user": get_user_info(request.user)}) @@ -63,8 +58,8 @@ class UserClubs(APIView): permission_classes = [IsAuthenticated] - def get(self, request: Request) -> Response: - club_data: ClubData = [ + def get(self, request: AuthRequest) -> Response: + club_data = [ get_club_info(request.user, club["club"]["code"]) for club in get_user_clubs(request.user) ] @@ -104,21 +99,23 @@ class Polls(viewsets.ModelViewSet[Poll]): def get_queryset(self) -> PollQuerySet: # all polls if superuser, polls corresponding to club for regular user + user = get_auth_user(self.request) return ( Poll.objects.all() - if self.request.user.is_superuser + if user.is_superuser else Poll.objects.filter( - club_code__in=[x["club"]["code"] for x in get_user_clubs(self.request.user)] + club_code__in=[x["club"]["code"] for x in get_user_clubs(user)] ) ) @action(detail=False, methods=["post"]) - def browse(self, request: Request) -> Response: + def browse(self, request: AuthRequest) -> Response: """Returns list of all possible polls user can answer but has yet to For admins, returns list of all polls they have not voted for and have yet to expire """ id_hash = request.data["id_hash"] + user = get_auth_user(request) # unvoted polls in draft/approaved mode for superuser # unvoted and approved polls within time frame for regular user @@ -128,7 +125,7 @@ def browse(self, request: Request) -> Response: Q(status=Poll.STATUS_DRAFT) | Q(status=Poll.STATUS_APPROVED), expire_date__gte=timezone.localtime(), ) - if request.user.is_superuser + if user.is_superuser else Poll.objects.filter( ~Q(id__in=PollVote.objects.filter(id_hash=id_hash).values_list("poll_id")), status=Poll.STATUS_APPROVED, @@ -140,9 +137,9 @@ def browse(self, request: Request) -> Response: # list of polls where user doesn't identify with # target populations bad_polls = [] - if not request.user.is_superuser: + if not user.is_superuser: for unfiltered_poll in unfiltered_polls: - if not check_targets(unfiltered_poll, request.user): + if not check_targets(unfiltered_poll, user): bad_polls.append(unfiltered_poll.id) # excludes the bad polls @@ -175,7 +172,7 @@ def review(self, request: Request) -> Response: ) @action(detail=True, methods=["get"]) - def option_view(self, request: Request, pk: int = None) -> Response: + def option_view(self, request: Request, pk: Optional[int] = None) -> Response: """Returns information on specific poll, including options and vote counts""" return Response(RetrievePollSerializer(Poll.objects.filter(id=pk).first(), many=False).data) @@ -199,12 +196,13 @@ class PollOptions(viewsets.ModelViewSet[PollOption]): def get_queryset(self) -> PollOptionQuerySet: # if user is admin, they can update anything # if user is not admin, they can only update their own options + user = get_auth_user(self.request) return ( PollOption.objects.all() - if self.request.user.is_superuser + if user.is_superuser else PollOption.objects.filter( poll__in=Poll.objects.filter( - club_code__in=[x["club"]["code"] for x in get_user_clubs(self.request.user)] + club_code__in=[x["club"]["code"] for x in get_user_clubs(user)] ) ) ) @@ -239,7 +237,8 @@ def all(self, request: Request) -> Response: return Response(RetrievePollVoteSerializer(poll_votes, many=True).data) def create(self, request: Request, *args: Any, **kwargs: Any) -> Response: - record_analytics(Metric.PORTAL_POLL_VOTED, request.user.username) + user = get_auth_user(request) + record_analytics(Metric.PORTAL_POLL_VOTED, user.username) return super().create(request, *args, **kwargs) @@ -257,7 +256,7 @@ def get(self, request: Request, poll_id: int) -> Response: .order_by("date") ) - statistics: VoteStatistics = { + statistics: dict[str, Any] = { "time_series": time_series, "poll_statistics": get_demographic_breakdown(poll_id), } @@ -286,26 +285,28 @@ class Posts(viewsets.ModelViewSet[Post]): serializer_class = PostSerializer def get_queryset(self) -> PostQuerySet: + user = get_auth_user(self.request) return ( Post.objects.all() - if self.request.user.is_superuser + if user.is_superuser else Post.objects.filter( - club_code__in=[x["club"]["code"] for x in get_user_clubs(self.request.user)] + club_code__in=[x["club"]["code"] for x in get_user_clubs(user)] ) ) @action(detail=False, methods=["get"]) - def browse(self, request: Request) -> Response: + def browse(self, request: AuthRequest) -> Response: """ Returns a list of all posts that are targeted at the current user For admins, returns list of posts that they have not approved and have yet to expire """ + user = get_auth_user(request) unfiltered_posts = ( Post.objects.filter( Q(status=Post.STATUS_DRAFT) | Q(status=Post.STATUS_APPROVED), expire_date__gte=timezone.localtime(), ) - if request.user.is_superuser + if user.is_superuser else Post.objects.filter( status=Post.STATUS_APPROVED, start_date__lte=timezone.localtime(), diff --git a/backend/setup.cfg b/backend/setup.cfg index 278639ed..afca0145 100644 --- a/backend/setup.cfg +++ b/backend/setup.cfg @@ -2,7 +2,8 @@ max-line-length = 100 exclude = .venv, migrations inline-quotes = double -ignore = E203, W503 +ignore = E203, W503, E704 +extend-select = F841, F401 [isort] default_section = THIRDPARTY diff --git a/backend/sublet/admin.py b/backend/sublet/admin.py index 0dbdd521..8e7fa78a 100644 --- a/backend/sublet/admin.py +++ b/backend/sublet/admin.py @@ -1,15 +1,15 @@ from django.contrib import admin -from django.utils.html import mark_safe +from django.utils.safestring import SafeText, mark_safe from sublet.models import Amenity, Offer, Sublet, SubletImage class SubletAdmin(admin.ModelAdmin): - def image_tag(self, instance): + def image_tag(self, instance: Sublet) -> SafeText: images = ['' for image in instance.images.all()] return mark_safe("
".join(images)) - image_tag.short_description = "Sublet Images" + image_tag.short_description = "Sublet Images" # type: ignore[attr-defined] readonly_fields = ("image_tag",) diff --git a/backend/sublet/migrations/0001_initial.py b/backend/sublet/migrations/0001_initial.py index 5245debc..7e7f7948 100644 --- a/backend/sublet/migrations/0001_initial.py +++ b/backend/sublet/migrations/0001_initial.py @@ -10,16 +10,12 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] + dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] operations = [ migrations.CreateModel( name="Amenity", - fields=[ - ("name", models.CharField(max_length=255, primary_key=True, serialize=False)), - ], + fields=[("name", models.CharField(max_length=255, primary_key=True, serialize=False))], ), migrations.CreateModel( name="Offer", diff --git a/backend/sublet/migrations/0002_auto_20240209_1649.py b/backend/sublet/migrations/0002_auto_20240209_1649.py index 35943aab..85d431b6 100644 --- a/backend/sublet/migrations/0002_auto_20240209_1649.py +++ b/backend/sublet/migrations/0002_auto_20240209_1649.py @@ -5,23 +5,12 @@ class Migration(migrations.Migration): - dependencies = [ - ("sublet", "0001_initial"), - ] + dependencies = [("sublet", "0001_initial")] operations = [ - migrations.RenameField( - model_name="sublet", - old_name="max_price", - new_name="price", - ), - migrations.RemoveField( - model_name="sublet", - name="min_price", - ), + migrations.RenameField(model_name="sublet", old_name="max_price", new_name="price"), + migrations.RemoveField(model_name="sublet", name="min_price"), migrations.AddField( - model_name="sublet", - name="negotiable", - field=models.BooleanField(default=True), + model_name="sublet", name="negotiable", field=models.BooleanField(default=True) ), ] diff --git a/backend/sublet/migrations/0003_alter_sublet_baths.py b/backend/sublet/migrations/0003_alter_sublet_baths.py index 7f8b65b9..bc0e7280 100644 --- a/backend/sublet/migrations/0003_alter_sublet_baths.py +++ b/backend/sublet/migrations/0003_alter_sublet_baths.py @@ -5,14 +5,12 @@ class Migration(migrations.Migration): - dependencies = [ - ("sublet", "0002_auto_20240209_1649"), - ] + dependencies = [("sublet", "0002_auto_20240209_1649")] operations = [ migrations.AlterField( model_name="sublet", name="baths", field=models.DecimalField(blank=True, decimal_places=1, max_digits=3, null=True), - ), + ) ] diff --git a/backend/sublet/migrations/0004_alter_sublet_external_link.py b/backend/sublet/migrations/0004_alter_sublet_external_link.py index 5e01a6ce..01a30b39 100644 --- a/backend/sublet/migrations/0004_alter_sublet_external_link.py +++ b/backend/sublet/migrations/0004_alter_sublet_external_link.py @@ -5,14 +5,12 @@ class Migration(migrations.Migration): - dependencies = [ - ("sublet", "0003_alter_sublet_baths"), - ] + dependencies = [("sublet", "0003_alter_sublet_baths")] operations = [ migrations.AlterField( model_name="sublet", name="external_link", field=models.URLField(blank=True, max_length=255, null=True), - ), + ) ] diff --git a/backend/sublet/models.py b/backend/sublet/models.py index e391053a..4447b9d4 100644 --- a/backend/sublet/models.py +++ b/backend/sublet/models.py @@ -1,5 +1,6 @@ from django.contrib.auth import get_user_model from django.db import models +from django.db.models import QuerySet from phonenumber_field.modelfields import PhoneNumberField @@ -10,49 +11,64 @@ class Offer(models.Model): class Meta: constraints = [models.UniqueConstraint(fields=["user", "sublet"], name="unique_offer")] - user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="offers_made") - sublet = models.ForeignKey("Sublet", on_delete=models.CASCADE, related_name="offers") - email = models.EmailField(max_length=255, null=True, blank=True) - phone_number = PhoneNumberField(null=True, blank=True) - message = models.CharField(max_length=255, blank=True) - created_date = models.DateTimeField(auto_now_add=True) + id: int + user: models.ForeignKey = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="offers_made" + ) + sublet: models.ForeignKey = models.ForeignKey( + "Sublet", on_delete=models.CASCADE, related_name="offers" + ) + email: models.EmailField = models.EmailField(max_length=255, null=True, blank=True) + phone_number: PhoneNumberField = PhoneNumberField(null=True, blank=True) + message: models.CharField = models.CharField(max_length=255, blank=True) + created_date: models.DateTimeField = models.DateTimeField(auto_now_add=True) - def __str__(self): + def __str__(self) -> str: return f"Offer for {self.sublet} made by {self.user}" class Amenity(models.Model): - name = models.CharField(max_length=255, primary_key=True) + name: models.CharField = models.CharField(max_length=255, primary_key=True) - def __str__(self): + def __str__(self) -> str: return self.name class Sublet(models.Model): - subletter = models.ForeignKey(User, on_delete=models.CASCADE) - sublettees = models.ManyToManyField( + id: int + subletter: models.ForeignKey = models.ForeignKey(User, on_delete=models.CASCADE) + sublettees: models.ManyToManyField = models.ManyToManyField( User, through=Offer, related_name="sublets_offered", blank=True ) - favorites = models.ManyToManyField(User, related_name="sublets_favorited", blank=True) - amenities = models.ManyToManyField(Amenity, blank=True) - - title = models.CharField(max_length=255) - address = models.CharField(max_length=255, null=True, blank=True) - beds = models.IntegerField(null=True, blank=True) - baths = models.DecimalField(max_digits=3, decimal_places=1, null=True, blank=True) - description = models.TextField(null=True, blank=True) - external_link = models.URLField(max_length=255, null=True, blank=True) - price = models.IntegerField() - negotiable = models.BooleanField(default=True) - created_at = models.DateTimeField(auto_now_add=True) - expires_at = models.DateTimeField() - start_date = models.DateField() - end_date = models.DateField() - - def __str__(self): + favorites: models.ManyToManyField = models.ManyToManyField( + User, related_name="sublets_favorited", blank=True + ) + amenities: models.ManyToManyField = models.ManyToManyField(Amenity, blank=True) + + title: models.CharField = models.CharField(max_length=255) + address: models.CharField = models.CharField(max_length=255, null=True, blank=True) + beds: models.IntegerField = models.IntegerField(null=True, blank=True) + baths: models.DecimalField = models.DecimalField( + max_digits=3, decimal_places=1, null=True, blank=True + ) + description: models.TextField = models.TextField(null=True, blank=True) + external_link: models.URLField = models.URLField(max_length=255, null=True, blank=True) + price: models.IntegerField = models.IntegerField() + negotiable: models.BooleanField = models.BooleanField(default=True) + created_at: models.DateTimeField = models.DateTimeField(auto_now_add=True) + expires_at: models.DateTimeField = models.DateTimeField() + start_date: models.DateField = models.DateField() + end_date: models.DateField = models.DateField() + + images: QuerySet + + def __str__(self) -> str: return f"{self.title} by {self.subletter}" class SubletImage(models.Model): - sublet = models.ForeignKey(Sublet, on_delete=models.CASCADE, related_name="images") - image = models.ImageField(upload_to="sublet/images") + id: int + sublet: models.ForeignKey = models.ForeignKey( + Sublet, on_delete=models.CASCADE, related_name="images" + ) + image: models.ImageField = models.ImageField(upload_to="sublet/images") diff --git a/backend/sublet/permissions.py b/backend/sublet/permissions.py index c1aeb314..69a5f3de 100644 --- a/backend/sublet/permissions.py +++ b/backend/sublet/permissions.py @@ -1,4 +1,9 @@ +from typing import Any + from rest_framework import permissions +from rest_framework.request import Request + +from utils.types import get_auth_user class IsSuperUser(permissions.BasePermission): @@ -6,11 +11,11 @@ class IsSuperUser(permissions.BasePermission): Grants permission if the current user is a superuser. """ - def has_object_permission(self, request, view, obj): - return request.user.is_superuser + def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool: + return get_auth_user(request).is_superuser - def has_permission(self, request, view): - return request.user.is_superuser + def has_permission(self, request: Request, view: Any) -> bool: + return get_auth_user(request).is_superuser class SubletOwnerPermission(permissions.BasePermission): @@ -18,14 +23,14 @@ class SubletOwnerPermission(permissions.BasePermission): Custom permission to allow the owner of a Sublet to edit or delete it. """ - def has_permission(self, request, view): - return request.user.is_authenticated + def has_permission(self, request: Request, view: Any) -> bool: + return get_auth_user(request).is_authenticated - def has_object_permission(self, request, view, obj): + def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool: # Check if the user is the owner of the Sublet. if request.method in permissions.SAFE_METHODS: return True - return obj.subletter == request.user + return obj.subletter == get_auth_user(request) class SubletImageOwnerPermission(permissions.BasePermission): @@ -33,12 +38,14 @@ class SubletImageOwnerPermission(permissions.BasePermission): Custom permission to allow the owner of a SubletImage to edit or delete it. """ - def has_permission(self, request, view): - return request.user.is_authenticated + def has_permission(self, request: Request, view: Any) -> bool: + return get_auth_user(request).is_authenticated - def has_object_permission(self, request, view, obj): + def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool: # Check if the user is the owner of the Sublet. - return request.method in permissions.SAFE_METHODS or obj.sublet.subletter == request.user + return request.method in permissions.SAFE_METHODS or obj.sublet.subletter == get_auth_user( + request + ) class OfferOwnerPermission(permissions.BasePermission): @@ -46,12 +53,12 @@ class OfferOwnerPermission(permissions.BasePermission): Custom permission to allow owner of an offer to delete it. """ - def has_permission(self, request, view): - return request.user.is_authenticated + def has_permission(self, request: Request, view: Any) -> bool: + return get_auth_user(request).is_authenticated - def has_object_permission(self, request, view, obj): + def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool: if request.method in permissions.SAFE_METHODS: # Check if the user owns the sublet when getting list - return obj.subletter == request.user + return obj.subletter == get_auth_user(request) # This is redundant, here for safety - return obj.user == request.user + return obj.user == get_auth_user(request) diff --git a/backend/sublet/serializers.py b/backend/sublet/serializers.py index 5622ae07..e211b675 100644 --- a/backend/sublet/serializers.py +++ b/backend/sublet/serializers.py @@ -1,12 +1,13 @@ -from datetime import datetime from typing import Any, Optional, cast from phonenumber_field.serializerfields import PhoneNumberField from profanity_check import predict from rest_framework import serializers +from rest_framework.relations import PrimaryKeyRelatedField from rest_framework.request import Request from sublet.models import Amenity, Offer, Sublet, SubletImage +from utils.types import get_auth_user class BaseModelSerializer(serializers.ModelSerializer): @@ -15,7 +16,7 @@ def get_request(self) -> Request: class AmenitySerializer(BaseModelSerializer): - name: str = serializers.CharField(max_length=255) + name = serializers.CharField(max_length=255) class Meta: model = Amenity @@ -23,10 +24,10 @@ class Meta: class OfferSerializer(BaseModelSerializer): - phone_number: str = PhoneNumberField() - email: Optional[str] = serializers.EmailField(allow_null=True) - message: str = serializers.CharField(max_length=255) - created_date: datetime = serializers.DateTimeField(read_only=True) + phone_number = PhoneNumberField() + email = serializers.EmailField(allow_null=True) + message = serializers.CharField(max_length=255) + created_date = serializers.DateTimeField(read_only=True) class Meta: model = Offer @@ -51,7 +52,7 @@ class Meta: class SubletImageURLSerializer(BaseModelSerializer): image_url = serializers.SerializerMethodField("get_image_url") - def get_image_url(self, obj) -> Optional[str]: + def get_image_url(self, obj: SubletImage) -> Optional[str]: if not obj.image: return None @@ -71,7 +72,7 @@ class Meta: class SubletSerializer(BaseModelSerializer): # amenities = AmenitySerializer(many=True, required=False) # images = SubletImageURLSerializer(many=True, required=False) - amenities: list[Amenity] = serializers.PrimaryKeyRelatedField( + amenities: PrimaryKeyRelatedField = serializers.PrimaryKeyRelatedField( many=True, queryset=Amenity.objects.all(), required=False ) @@ -126,7 +127,7 @@ def create(self, validated_data: dict[str, Any]) -> Sublet: # delete_images is a list of image ids to delete def update(self, instance: Sublet, validated_data: dict[str, Any]) -> Sublet: # Check if the user is the subletter before allowing the update - user = self.get_request().user + user = get_auth_user(self.get_request()) if user == instance.subletter or user.is_superuser: instance = super().update(instance, validated_data) instance.save() @@ -135,7 +136,7 @@ def update(self, instance: Sublet, validated_data: dict[str, Any]) -> Sublet: def destroy(self, instance: Sublet) -> None: # Check if the user is the subletter before allowing the delete - user = self.get_request().user + user = get_auth_user(self.get_request()) if user == instance.subletter or user.is_superuser: instance.delete() else: @@ -143,10 +144,10 @@ def destroy(self, instance: Sublet) -> None: class SubletSerializerRead(BaseModelSerializer): - amenities: list[Amenity] = serializers.PrimaryKeyRelatedField( + amenities: PrimaryKeyRelatedField = serializers.PrimaryKeyRelatedField( many=True, queryset=Amenity.objects.all(), required=False ) - images: list[SubletImage] = SubletImageURLSerializer(many=True, required=False) + images = SubletImageURLSerializer(many=True, required=False) class Meta: model = Sublet @@ -178,10 +179,10 @@ def to_representation(self, instance: Sublet) -> dict[str, Any]: # simple sublet serializer for use when pulling all serializers/etc class SubletSerializerSimple(BaseModelSerializer): - amenities: list[Amenity] = serializers.PrimaryKeyRelatedField( + amenities: PrimaryKeyRelatedField = serializers.PrimaryKeyRelatedField( many=True, queryset=Amenity.objects.all(), required=False ) - images: list[SubletImage] = SubletImageURLSerializer(many=True, required=False) + images = SubletImageURLSerializer(many=True, required=False) class Meta: model = Sublet diff --git a/backend/sublet/views.py b/backend/sublet/views.py index 3509a1aa..c749514c 100644 --- a/backend/sublet/views.py +++ b/backend/sublet/views.py @@ -1,7 +1,7 @@ -from typing import Any, TypeAlias +from typing import Any, Type, TypeAlias, cast -from django.contrib.auth import get_user_model from django.db.models import Manager, QuerySet, prefetch_related_objects +from django.http import QueryDict from django.utils import timezone from django.utils.dateparse import parse_date from rest_framework import exceptions, generics, mixins, status, viewsets @@ -21,6 +21,7 @@ ) from sublet.serializers import ( AmenitySerializer, + BaseModelSerializer, OfferSerializer, SubletImageSerializer, SubletImageURLSerializer, @@ -28,16 +29,15 @@ SubletSerializerRead, SubletSerializerSimple, ) +from utils.types import get_user SubletQuerySet: TypeAlias = QuerySet[Sublet, Manager[Sublet]] OfferQuerySet: TypeAlias = QuerySet[Offer, Manager[Offer]] +AmenityQuerySet: TypeAlias = QuerySet[Amenity, Manager[Amenity]] ImageList: TypeAlias = QuerySet[SubletImage, Manager[SubletImage]] -FavoriteQuerySet: TypeAlias = QuerySet[Sublet, Manager[Sublet]] UserOfferQuerySet: TypeAlias = QuerySet[Offer, Manager[Offer]] -User = get_user_model() - class Amenities(generics.ListAPIView): serializer_class = AmenitySerializer @@ -52,9 +52,8 @@ class UserFavorites(generics.ListAPIView): serializer_class = SubletSerializerSimple permission_classes = [IsAuthenticated] - def get_queryset(self) -> FavoriteQuerySet: - user = self.request.user - return user.sublets_favorited + def get_queryset(self) -> SubletQuerySet: + return get_user(self.request).sublets_favorited class UserOffers(generics.ListAPIView): @@ -62,8 +61,7 @@ class UserOffers(generics.ListAPIView): permission_classes = [IsAuthenticated] def get_queryset(self) -> UserOfferQuerySet: - user = self.request.user - return Offer.objects.filter(user=user) + return Offer.objects.filter(user=get_user(self.request)) class Properties(viewsets.ModelViewSet): @@ -83,7 +81,7 @@ class Properties(viewsets.ModelViewSet): permission_classes = [SubletOwnerPermission | IsSuperUser] - def get_serializer_class(self): + def get_serializer_class(self) -> Type[BaseModelSerializer]: return SubletSerializerRead if self.action == "retrieve" else SubletSerializer def get_queryset(self) -> SubletQuerySet: @@ -95,7 +93,7 @@ def create(self, request: Request, *args: Any, **kwargs: Any) -> Response: instance = serializer.save() # Create the Sublet instance_serializer = SubletSerializerRead(instance=instance, context={"request": request}) - record_analytics(Metric.SUBLET_CREATED, request.user.username) + record_analytics(Metric.SUBLET_CREATED, get_user(request).username) return Response(instance_serializer.data, status=status.HTTP_201_CREATED) @@ -108,7 +106,7 @@ def update(self, request: Request, *args: Any, **kwargs: Any) -> Response: queryset = self.filter_queryset(self.get_queryset()) # no clue what this does but I copied it from the DRF source code - if queryset._prefetch_related_lookups: + if hasattr(queryset, "_prefetch_related_lookups"): # If 'prefetch_related' has been applied to a queryset, we need to # forcibly invalidate the prefetch cache on the instance, # and then re-prefetch related objects @@ -142,7 +140,7 @@ def list(self, request: Request, *args: Any, **kwargs: Any) -> Response: queryset: SubletQuerySet = self.get_queryset() if params.get("subletter", "false").lower() == "true": - queryset = queryset.filter(subletter=request.user) + queryset = queryset.filter(subletter=get_user(request)) else: queryset = queryset.filter(expires_at__gte=timezone.now()) @@ -188,7 +186,7 @@ def list(self, request: Request, *args: Any, **kwargs: Any) -> Response: for amenity in amenities: queryset = queryset.filter(amenities__name=amenity) - record_analytics(Metric.SUBLET_BROWSE, request.user.username) + record_analytics(Metric.SUBLET_BROWSE, get_user(request).username) serializer = SubletSerializerSimple(queryset, many=True) return Response(serializer.data) @@ -198,10 +196,7 @@ class CreateImages(generics.CreateAPIView): serializer_class = SubletImageSerializer http_method_names = ["post"] permission_classes = [SubletImageOwnerPermission | IsSuperUser] - parser_classes = ( - MultiPartParser, - FormParser, - ) + parser_classes = (MultiPartParser, FormParser) def get_queryset(self, *args: Any, **kwargs: Any) -> ImageList: sublet = get_object_or_404(Sublet, id=int(self.kwargs["sublet_id"])) @@ -209,7 +204,8 @@ def get_queryset(self, *args: Any, **kwargs: Any) -> ImageList: # takes an image multipart form data and creates a new image object def post(self, request: Request, *args: Any, **kwargs: Any) -> Response: - images = request.data.getlist("images") + data = cast(QueryDict, request.data) + images = data.getlist("images") sublet_id = int(self.kwargs["sublet_id"]) self.get_queryset() # check if sublet exists img_serializers = [] @@ -218,8 +214,10 @@ def post(self, request: Request, *args: Any, **kwargs: Any) -> Response: img_serializer.is_valid(raise_exception=True) img_serializers.append(img_serializer) instances = [img_serializer.save() for img_serializer in img_serializers] - data = [SubletImageURLSerializer(instance=instance).data for instance in instances] - return Response(data, status=status.HTTP_201_CREATED) + serialized_data = [ + SubletImageURLSerializer(instance=instance).data for instance in instances + ] + return Response(serialized_data, status=status.HTTP_201_CREATED) class DeleteImage(generics.DestroyAPIView): @@ -231,7 +229,7 @@ class DeleteImage(generics.DestroyAPIView): def destroy(self, request: Request, *args: Any, **kwargs: Any) -> Response: queryset = self.get_queryset() filter = {"id": self.kwargs["image_id"]} - obj = get_object_or_404(queryset, **filter) + obj: SubletImage = get_object_or_404(queryset, **filter) # checking permissions here is kind of redundant self.check_object_permissions(self.request, obj) self.perform_destroy(obj) @@ -243,8 +241,8 @@ class Favorites(mixins.DestroyModelMixin, mixins.CreateModelMixin, viewsets.Gene http_method_names = ["post", "delete"] permission_classes = [IsAuthenticated | IsSuperUser] - def get_queryset(self) -> FavoriteQuerySet: - user = self.request.user + def get_queryset(self) -> SubletQuerySet: + user = get_user(self.request) return user.sublets_favorited def create(self, request: Request, *args: Any, **kwargs: Any) -> Response: @@ -253,16 +251,16 @@ def create(self, request: Request, *args: Any, **kwargs: Any) -> Response: if queryset.filter(id=sublet_id).exists(): raise exceptions.NotAcceptable("Favorite already exists") sublet = get_object_or_404(Sublet, id=sublet_id) - self.get_queryset().add(sublet) + get_user(self.request).sublets_favorited.add(sublet) - record_analytics(Metric.SUBLET_FAVORITED, request.user.username) + record_analytics(Metric.SUBLET_FAVORITED, get_user(request).username) return Response(status=status.HTTP_201_CREATED) def destroy(self, request: Request, *args: Any, **kwargs: Any) -> Response: - queryset = self.get_queryset() - sublet = get_object_or_404(queryset, pk=int(self.kwargs["sublet_id"])) - self.get_queryset().remove(sublet) + queryset = cast(QuerySet, self.get_queryset()) + sublet: Sublet = get_object_or_404(queryset, pk=int(self.kwargs["sublet_id"])) + get_user(self.request).sublets_favorited.remove(sublet) return Response(status=status.HTTP_204_NO_CONTENT) @@ -288,22 +286,23 @@ def get_queryset(self) -> OfferQuerySet: def create(self, request: Request, *args: Any, **kwargs: Any) -> Response: data = request.data - request.POST._mutable = True - if self.get_queryset().filter(user=self.request.user).exists(): + if isinstance(data, QueryDict): + data._mutable = True + if self.get_queryset().filter(user=get_user(self.request)).exists(): raise exceptions.NotAcceptable("Offer already exists") data["sublet"] = int(self.kwargs["sublet_id"]) - data["user"] = self.request.user.id + data["user"] = get_user(self.request).id serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) serializer.save() - record_analytics(Metric.SUBLET_OFFER, request.user.username) + record_analytics(Metric.SUBLET_OFFER, get_user(request).username) return Response(serializer.data, status=status.HTTP_201_CREATED) def destroy(self, request: Request, *args: Any, **kwargs: Any) -> Response: queryset = self.get_queryset() - filter = {"user": self.request.user.id, "sublet": int(self.kwargs["sublet_id"])} + filter = {"user": get_user(self.request).id, "sublet": int(self.kwargs["sublet_id"])} obj: Offer = get_object_or_404(queryset, **filter) # checking permissions here is kind of redundant self.check_object_permissions(self.request, obj) diff --git a/backend/tests/dining/test_load_venues.py b/backend/tests/dining/test_load_venues.py index 596027ab..91b66586 100644 --- a/backend/tests/dining/test_load_venues.py +++ b/backend/tests/dining/test_load_venues.py @@ -7,13 +7,13 @@ class TestLoadVenues(TestCase): - def test(self): + def test(self) -> None: out = StringIO() call_command("load_venues", stdout=out) self.assertEqual(len(Venue.objects.all()), 16) - list_of_ids = [] + list_of_ids: list[int] = [] for venue in Venue.objects.all(): self.assertNotIn(venue.venue_id, list_of_ids) list_of_ids.append(venue.venue_id) diff --git a/backend/tests/dining/test_views.py b/backend/tests/dining/test_views.py index 2ab0c474..82d18049 100644 --- a/backend/tests/dining/test_views.py +++ b/backend/tests/dining/test_views.py @@ -1,8 +1,8 @@ import datetime import json +from typing import Any from unittest import mock -from django.contrib.auth import get_user_model from django.core.management import call_command from django.test import TestCase from django.urls import reverse @@ -10,18 +10,16 @@ from dining.api_wrapper import APIError, DiningAPIWrapper from dining.models import DiningMenu, Venue +from utils.types import DjangoUserModel, UserType -User = get_user_model() - - -def mock_dining_requests(url, *args, **kwargs): +def mock_dining_requests(url: str, *args: Any, **kwargs: Any) -> Any: class Mock: - def __init__(self, json_data, status_code): + def __init__(self, json_data: dict, status_code: int) -> None: self.json_data = json_data self.status_code = status_code - def json(self): + def json(self) -> dict: return self.json_data if "token" in url: @@ -37,23 +35,23 @@ def json(self): return Mock(json.load(data), 200) -def mock_request_raise_error(*args, **kwargs): +def mock_request_raise_error(*args: Any, **kwargs: Any) -> None: raise ConnectionError -def mock_request_post_error(*args, **kwargs): +def mock_request_post_error(*args: Any, **kwargs: Any) -> Any: class Mock: - def json(self): + def json(self) -> dict: return {"error": None} return Mock() class TestTokenAndRequest(TestCase): - def setUp(self): + def setUp(self) -> None: self.wrapper = DiningAPIWrapper() - def test_expired_token(self): + def test_expired_token(self) -> None: self.wrapper.expiration += datetime.timedelta(days=1) prev_token = self.wrapper.token prev_expiration = self.wrapper.expiration @@ -65,19 +63,19 @@ def test_expired_token(self): self.assertEqual(prev_expiration, self.wrapper.expiration) @mock.patch("requests.post", mock_request_post_error) - def test_update_token_error(self): + def test_update_token_error(self) -> None: with self.assertRaises(APIError): self.wrapper.update_token() @mock.patch("requests.post", mock_dining_requests) @mock.patch("requests.request", lambda **kwargs: None) - def test_request_headers_update(self): + def test_request_headers_update(self) -> None: res = self.wrapper.request(headers=dict()) self.assertIsNone(res) @mock.patch("requests.post", mock_dining_requests) @mock.patch("requests.request", mock_request_raise_error) - def test_request_api_error(self): + def test_request_api_error(self) -> None: with self.assertRaises(APIError): self.wrapper.request() @@ -85,10 +83,10 @@ def test_request_api_error(self): @mock.patch("requests.post", mock_dining_requests) @mock.patch("requests.request", mock_dining_requests) class TestVenues(TestCase): - def setUp(self): + def setUp(self) -> None: call_command("load_venues") - def test_get(self): + def test_get(self) -> None: response = self.client.get(reverse("venues")) for entry in response.json(): self.assertIn("name", entry) @@ -104,7 +102,7 @@ def test_get(self): class TestMenus(TestCase): @mock.patch("requests.post", mock_dining_requests) @mock.patch("requests.request", mock_dining_requests) - def setUp(self): + def setUp(self) -> None: Venue.objects.create( venue_id=593, name="1920 Commons", @@ -112,7 +110,7 @@ def setUp(self): ) call_command("load_next_menu") - def try_structure(self, data): + def try_structure(self, data: list[dict]) -> None: for entry in data: self.assertIn("venue", entry) self.assertIn("date", entry) @@ -131,16 +129,16 @@ def try_structure(self, data): self.assertIn("allergens", item) self.assertIn("nutrition_info", item) - def test_get_default(self): + def test_get_default(self) -> None: response = self.client.get(reverse("menus")) self.try_structure(response.json()) - def test_get_date(self): + def test_get_date(self) -> None: response = self.client.get("/dining/menus/2022-10-04/") self.try_structure(response.json()) @mock.patch("requests.request", mock_dining_requests) - def test_skip_venue(self): + def test_skip_venue(self) -> None: Venue.objects.all().delete() Venue.objects.create(venue_id=747, name="Skip", image_url="URL") wrapper = DiningAPIWrapper() @@ -149,11 +147,11 @@ def test_skip_venue(self): class TestPreferences(TestCase): - def setUp(self): + def setUp(self) -> None: call_command("load_venues") - self.client = APIClient() + self.client: APIClient = APIClient() - self.test_user = User.objects.create_user("user", "user@a.com", "user") + self.test_user: UserType = DjangoUserModel.objects.create_user("user", "user@a.com", "user") preference = self.test_user.profile.dining_preferences preference.add(Venue.objects.get(venue_id=593)) @@ -163,7 +161,7 @@ def setUp(self): preference.add(Venue.objects.get(venue_id=636)) preference.add(Venue.objects.get(venue_id=637)) - def test_get(self): + def test_get(self) -> None: self.client.force_authenticate(user=self.test_user) response = self.client.get(reverse("dining-preferences")) @@ -177,7 +175,7 @@ def test_get(self): else: self.assertEqual(item["count"], 1) - def test_post(self): + def test_post(self) -> None: self.client.force_authenticate(user=self.test_user) self.client.post( reverse("dining-preferences"), diff --git a/backend/tests/gsr_booking/test_gsr_booking.py b/backend/tests/gsr_booking/test_gsr_booking.py index 1559dbbd..89b0bdb0 100644 --- a/backend/tests/gsr_booking/test_gsr_booking.py +++ b/backend/tests/gsr_booking/test_gsr_booking.py @@ -1,89 +1,87 @@ -from django.contrib.auth import get_user_model from django.test import TestCase from rest_framework.test import APIClient from gsr_booking.models import Group, GroupMembership +from utils.types import DjangoUserModel, UserType -User = get_user_model() - - -class UserViewTestCase(TestCase): - def setUp(self): - self.user1 = User.objects.create_user( +class MyMembershipViewTestCase(TestCase): + def setUp(self) -> None: + self.user1: UserType = DjangoUserModel.objects.create_user( username="user1", password="password", first_name="user", last_name="one" ) - self.user2 = User.objects.create_user( + self.user2: UserType = DjangoUserModel.objects.create_user( username="user2", password="password", first_name="user", last_name="two" ) - self.group = Group.objects.create(owner=self.user1, name="g1", color="blue") - self.group.members.add(self.user1) - memship = self.group.memberships.all()[0] - memship.accepted = True - memship.save() + Group.objects.create( + owner=self.user1, name="g1", color="blue" + ) # creating group also adds user + group2 = Group.objects.create(owner=self.user2, name="g2", color="blue") + GroupMembership.objects.create(user=self.user1, group=group2, accepted=True) + group3 = Group.objects.create(owner=self.user2, name="g3", color="blue") + GroupMembership.objects.create(user=self.user1, group=group3) self.client = APIClient() self.client.login(username="user1", password="password") - def test_user_list(self): - response = self.client.get("/gsr/users/") + def test_user_memberships(self) -> None: + response = self.client.get("/gsr/mymemberships/") self.assertTrue(200, response.status_code) - self.assertEqual(2, len(response.data)) + self.assertEqual(2, len(response.data)) # type: ignore[attr-defined] - def test_user_detail_in_group(self): - response = self.client.get("/gsr/users/user1/") + def test_user_invites(self) -> None: + response = self.client.get("/gsr/mymemberships/invites/") self.assertTrue(200, response.status_code) - self.assertEqual(2, len(response.data["booking_groups"])) - - def test_me_user_detail_in_group(self): - response = self.client.get("/gsr/users/me/") - self.assertTrue(200, response.status_code) - self.assertEqual(2, len(response.data["booking_groups"])) + self.assertEqual(1, len(response.data)) # type: ignore[attr-defined] class MembershipViewTestCase(TestCase): - def setUp(self): - self.user1 = User.objects.create_user(username="user1", password="password") - self.user2 = User.objects.create_user(username="user2", password="password") + def setUp(self) -> None: + self.user1: UserType = DjangoUserModel.objects.create_user( + username="user1", password="password" + ) + self.user2: UserType = DjangoUserModel.objects.create_user( + username="user2", password="password" + ) self.group = Group.objects.create(owner=self.user1, name="g1", color="blue") self.group2 = Group.objects.create(owner=self.user2, name="g2", color="white") - self.client = APIClient() + self.client: APIClient = APIClient() self.client.login(username="user1", password="password") - def test_invite_single(self): + def test_invite_single(self) -> None: self.client.login(username="user2", password="password") response = self.client.post( "/gsr/membership/invite/", {"user": "user2", "group": self.group.pk} ) self.assertEqual(200, response.status_code) - def test_bulk_invite(self): - User.objects.create_user(username="user3", password="password") + def test_bulk_invite(self) -> None: + DjangoUserModel.objects.create_user(username="user3", password="password") self.client.login(username="user2", password="password") response = self.client.post( "/gsr/membership/invite/", {"user": "user2,user3", "group": self.group.pk} ) self.assertEqual(200, response.status_code) - def test_invite_no_permission(self): + def test_invite_no_permission(self) -> None: self.client.login(username="user2", password="password") response = self.client.post( "/gsr/membership/invite/", {"user": "user2", "group": self.group.pk} ) self.assertEqual(200, response.status_code) - def test_invite_logged_out_fails(self): + def test_invite_logged_out_fails(self) -> None: self.client.logout() response = self.client.post( "/gsr/membership/invite/", {"user": "user2", "group": self.group.pk} ) self.assertEqual(403, response.status_code) - def test_invite_bad_group_fails(self): + def test_invite_bad_group_fails(self) -> None: response = self.client.post("/gsr/membership/invite/", {"user": "user2", "group": 300}) self.assertEqual(404, response.status_code) - def test_duplicate_invite_fails(self): + def test_duplicate_invite_fails(self) -> None: GroupMembership.objects.create(user=self.user2, group=self.group, accepted=False) self.client.force_authenticate(user=self.user2) response = self.client.post( @@ -91,7 +89,7 @@ def test_duplicate_invite_fails(self): ) self.assertEqual(403, response.status_code) - def test_already_member_invite_fails(self): + def test_already_member_invite_fails(self) -> None: GroupMembership.objects.create(user=self.user2, group=self.group, accepted=True) self.client.force_authenticate(user=self.user2) response = self.client.post( @@ -99,44 +97,44 @@ def test_already_member_invite_fails(self): ) self.assertEqual(403, response.status_code) - def test_accept_invite(self): + def test_accept_invite(self) -> None: mem = GroupMembership.objects.create(user=self.user1, group=self.group2, accepted=False) response = self.client.post(f"/gsr/membership/{mem.pk}/accept/") self.assertEqual(200, response.status_code) self.assertTrue(GroupMembership.objects.get(pk=mem.pk).accepted) - def test_wrong_user_accept_invite_fails(self): - user3 = User.objects.create_user(username="user3", password="password") + def test_wrong_user_accept_invite_fails(self) -> None: + user3: UserType = DjangoUserModel.objects.create_user(username="user3", password="password") mem = GroupMembership.objects.create(user=user3, group=self.group2, accepted=False) response = self.client.post(f"/gsr/membership/{mem.pk}/accept/") self.assertEqual(403, response.status_code) self.assertFalse(GroupMembership.objects.get(pk=mem.pk).accepted) - def test_accept_invite_again_fails(self): + def test_accept_invite_again_fails(self) -> None: mem = GroupMembership.objects.create(user=self.user1, group=self.group2, accepted=True) response = self.client.post(f"/gsr/membership/{mem.pk}/accept/") self.assertEqual(404, response.status_code) - def test_decline_invite(self): + def test_decline_invite(self) -> None: mem = GroupMembership.objects.create(user=self.user1, group=self.group2, accepted=False) response = self.client.post(f"/gsr/membership/{mem.pk}/decline/") self.assertEqual(200, response.status_code) self.assertFalse(GroupMembership.objects.filter(pk=mem.pk).exists()) - def test_wrong_user_decline_invite_fails(self): - user3 = User.objects.create_user(username="user3", password="password") + def test_wrong_user_decline_invite_fails(self) -> None: + user3: UserType = DjangoUserModel.objects.create_user(username="user3", password="password") mem = GroupMembership.objects.create(user=user3, group=self.group2, accepted=False) response = self.client.post(f"/gsr/membership/{mem.pk}/decline/") self.assertEqual(403, response.status_code) self.assertTrue(GroupMembership.objects.filter(pk=mem.pk).exists()) self.assertFalse(GroupMembership.objects.get(pk=mem.pk).accepted) - def test_decline_invite_again_fails(self): + def test_decline_invite_again_fails(self) -> None: mem = GroupMembership.objects.create(user=self.user1, group=self.group2, accepted=True) response = self.client.post(f"/gsr/membership/{mem.pk}/decline/") self.assertEqual(404, response.status_code) - def test_promote_to_admin(self): + def test_promote_to_admin(self) -> None: GroupMembership.objects.create(user=self.user1, group=self.group, accepted=True, type="A") mem = GroupMembership.objects.create( user=self.user2, group=self.group, accepted=True, type="M" @@ -148,35 +146,39 @@ def test_promote_to_admin(self): class GroupTestCase(TestCase): - def setUp(self): - self.user1 = User.objects.create_user(username="user1", password="password") - self.user2 = User.objects.create_user(username="user2", password="password") + def setUp(self) -> None: + self.user1: UserType = DjangoUserModel.objects.create_user( + username="user1", password="password" + ) + self.user2: UserType = DjangoUserModel.objects.create_user( + username="user2", password="password" + ) self.group = Group.objects.create(owner=self.user1, name="g1", color="blue") self.group2 = Group.objects.create(owner=self.user2, name="g2", color="white") - self.client = APIClient() + self.client: APIClient = APIClient() self.client.login(username="user1", password="password") - def test_get_groups(self): + def test_get_groups(self) -> None: response = self.client.get("/gsr/groups/") self.assertEqual(200, response.status_code) - self.assertEqual(2, len(response.data)) + self.assertEqual(1, len(response.data)) - def test_get_groups_includes_invites(self): + def test_get_groups_includes_invites(self) -> None: GroupMembership.objects.create(user=self.user1, group=self.group2, accepted=False) response = self.client.get(f"/gsr/groups/{self.group2.pk}/") self.assertEqual(200, response.status_code) - def test_get_group_not_involved_fails(self): + def test_get_group_not_involved_fails(self) -> None: response = self.client.get(f"/gsr/groups/{self.group2.pk}/") self.assertEqual(404, response.status_code) - def test_make_group(self): + def test_make_group(self) -> None: response = self.client.post("/gsr/groups/", {"name": "gx", "color": "blue"}) self.assertEqual(201, response.status_code, response.data) - self.assertEqual(5, Group.objects.count()) + self.assertEqual(3, Group.objects.count()) self.assertEqual("user1", Group.objects.get(name="gx").owner.username) - def test_only_accepted_memberships(self): + def test_only_accepted_memberships(self) -> None: gm = GroupMembership.objects.create(user=self.user2, group=self.group, accepted=False) response = self.client.get(f"/gsr/groups/{self.group.pk}/") self.assertEqual(200, response.status_code) diff --git a/backend/tests/gsr_booking/test_gsr_views.py b/backend/tests/gsr_booking/test_gsr_views.py index c749d2b9..3dcbb772 100644 --- a/backend/tests/gsr_booking/test_gsr_views.py +++ b/backend/tests/gsr_booking/test_gsr_views.py @@ -1,56 +1,56 @@ import json +from typing import Any from unittest import mock -from django.contrib.auth import get_user_model from django.core.management import call_command from django.test import TestCase from django.urls import reverse from rest_framework.test import APIClient from gsr_booking.models import GSR, Group, GSRBooking +from utils.types import DjangoUserModel, UserType -User = get_user_model() - - -def is_wharton_false(*args): +def is_wharton_false(*args: Any) -> bool: return False -def is_wharton_true(*args): +def is_wharton_true(*args: Any) -> bool: return True -def libcal_availability(*args): +def libcal_availability(*args: Any) -> list[dict]: with open("tests/gsr_booking/views_libcal_availability.json") as data: return json.load(data) -def wharton_availability(*args): +def wharton_availability(*args: Any) -> list[dict]: with open("tests/gsr_booking/views_wharton_availability.json") as data: return json.load(data) -def book_cancel_room(*args): +def book_cancel_room(*args: Any) -> None: pass -def reservations(*args): +def reservations(*args: Any) -> list[dict]: with open("tests/gsr_booking/views_reservations.json") as data: return json.load(data) class TestGSRs(TestCase): - def setUp(self): + def setUp(self) -> None: call_command("load_gsrs") - self.user = User.objects.create_user("user", "user@seas.upenn.edu", "user") - self.client = APIClient() + self.user: UserType = DjangoUserModel.objects.create_user( + "user", "user@seas.upenn.edu", "user" + ) + self.client: APIClient = APIClient() self.client.force_authenticate(user=self.user) - test_user = User.objects.create_user("user1", "user") + test_user = DjangoUserModel.objects.create_user("user1", "user") Group.objects.create(owner=test_user, name="Penn Labs", color="blue") - def test_get_location(self): + def test_get_location(self) -> None: response = self.client.get(reverse("locations")) res_json = json.loads(response.content) for entry in res_json: @@ -63,16 +63,18 @@ def test_get_location(self): class TestGSRFunctions(TestCase): - def setUp(self): + def setUp(self) -> None: call_command("load_gsrs") - self.user = User.objects.create_user("user", "user@sas.upenn.edu", "user") - self.client = APIClient() + self.user: UserType = DjangoUserModel.objects.create_user( + "user", "user@sas.upenn.edu", "user" + ) + self.client: APIClient = APIClient() self.client.force_authenticate(user=self.user) - test_user = User.objects.create_user("user1", "user") + test_user = DjangoUserModel.objects.create_user("user1", "user") Group.objects.create(owner=test_user, name="Penn Labs", color="blue") - def test_recent(self): + def test_recent(self) -> None: gsrs = list(GSR.objects.all()) GSRBooking.objects.create(user=self.user, room_id=1, room_name="Room 1", gsr=gsrs[0]) GSRBooking.objects.create(user=self.user, room_id=3, room_name="Room 3", gsr=gsrs[0]) @@ -93,21 +95,21 @@ def test_recent(self): self.assertNotEqual(res_json[0]["id"], res_json[1]["id"]) @mock.patch("gsr_booking.api_wrapper.WhartonBookingWrapper.is_wharton", is_wharton_false) - def test_get_wharton_false(self): + def test_get_wharton_false(self) -> None: response = self.client.get(reverse("is-wharton")) res_json = json.loads(response.content) self.assertEqual(1, len(res_json)) self.assertFalse(res_json["is_wharton"]) @mock.patch("gsr_booking.api_wrapper.WhartonBookingWrapper.is_wharton", is_wharton_true) - def test_get_wharton_true(self): + def test_get_wharton_true(self) -> None: response = self.client.get(reverse("is-wharton")) res_json = json.loads(response.content) self.assertEqual(1, len(res_json)) self.assertTrue(res_json["is_wharton"]) @mock.patch("gsr_booking.api_wrapper.BookingHandler.get_availability", libcal_availability) - def test_availability_libcal(self): + def test_availability_libcal(self) -> None: response = self.client.get(reverse("availability", args=["1086", "1889"])) res_json = json.loads(response.content) self.assertEqual(3, len(res_json)) @@ -121,7 +123,7 @@ def test_availability_libcal(self): self.assertIn("availability", room) @mock.patch("gsr_booking.api_wrapper.BookingHandler.get_availability", wharton_availability) - def test_availability_wharton(self): + def test_availability_wharton(self) -> None: response = self.client.get(reverse("availability", args=["JMHH", "1"])) res_json = json.loads(response.content) self.assertEqual(3, len(res_json)) @@ -135,7 +137,7 @@ def test_availability_wharton(self): self.assertIn("availability", room) @mock.patch("gsr_booking.api_wrapper.BookingHandler.book_room", book_cancel_room) - def test_book_libcal(self): + def test_book_libcal(self) -> None: payload = { "start_time": "2021-11-21T18:30:00-05:00", "end_time": "2021-11-21T19:00:00-05:00", @@ -151,7 +153,7 @@ def test_book_libcal(self): self.assertEqual("success", res_json["detail"]) @mock.patch("gsr_booking.api_wrapper.BookingHandler.book_room", book_cancel_room) - def test_book_wharton(self): + def test_book_wharton(self) -> None: payload = { "start_time": "2021-11-21T18:30:00-05:00", "end_time": "2021-11-21T19:00:00-05:00", @@ -167,7 +169,7 @@ def test_book_wharton(self): self.assertEqual("success", res_json["detail"]) @mock.patch("gsr_booking.api_wrapper.BookingHandler.cancel_room", book_cancel_room) - def test_cancel_room(self): + def test_cancel_room(self) -> None: payload = {"booking_id": "booking id"} response = self.client.post( reverse("cancel"), json.dumps(payload), content_type="application/json" @@ -177,7 +179,7 @@ def test_cancel_room(self): self.assertEqual("success", res_json["detail"]) @mock.patch("gsr_booking.api_wrapper.BookingHandler.get_reservations", reservations) - def test_reservations(self): + def test_reservations(self) -> None: response = self.client.get(reverse("reservations")) res_json = json.loads(response.content) self.assertEqual(6, len(res_json)) diff --git a/backend/tests/gsr_booking/test_gsr_wrapper.py b/backend/tests/gsr_booking/test_gsr_wrapper.py index aa214b95..23a307c3 100644 --- a/backend/tests/gsr_booking/test_gsr_wrapper.py +++ b/backend/tests/gsr_booking/test_gsr_wrapper.py @@ -1,8 +1,8 @@ import json from datetime import timedelta +from typing import Any, cast from unittest import mock -from django.contrib.auth import get_user_model from django.core.management import call_command from django.test import TestCase from django.utils import timezone @@ -10,19 +10,17 @@ from gsr_booking.api_wrapper import APIError, GSRBooker, WhartonGSRBooker from gsr_booking.models import GSR, Group, GroupMembership, GSRBooking, Reservation +from utils.types import DjangoUserModel, UserType -User = get_user_model() - - -def mock_requests_get(obj, *args, **kwargs): +def mock_requests_get(obj: Any, *args: Any, **kwargs: Any) -> Any: class Mock: - def __init__(self, json_data, status_code): + def __init__(self, json_data: dict, status_code: int) -> None: self.json_data = json_data self.status_code = status_code self.ok = True - def json(self): + def json(self) -> dict: return self.json_data url = args[1] @@ -52,22 +50,24 @@ def json(self): class TestBookingWrapper(TestCase): - def setUp(self): + def setUp(self) -> None: call_command("load_gsrs") - self.user = User.objects.create_user("user", "user@seas.upenn.edu", "user") - self.group_user = User.objects.create_user( + self.user: UserType = DjangoUserModel.objects.create_user( + "user", "user@seas.upenn.edu", "user" + ) + self.group_user: UserType = DjangoUserModel.objects.create_user( "grou_user", "group_user@seas.upenn.edu", "group_user" ) - self.client = APIClient() + self.client: APIClient = APIClient() self.client.force_authenticate(user=self.user) self.group = Group.objects.create(owner=self.group_user, name="Penn Labs", color="blue") @mock.patch("gsr_booking.api_wrapper.WhartonBookingWrapper.request", mock_requests_get) - def test_is_wharton(self): + def test_is_wharton(self) -> None: self.assertFalse(WhartonGSRBooker.is_wharton(self.user)) @mock.patch("gsr_booking.api_wrapper.WhartonBookingWrapper.request", mock_requests_get) - def test_wharton_availability(self): + def test_wharton_availability(self) -> None: availability = GSRBooker.get_availability("JMHH", 1, "2021-01-07", "2022-01-08", self.user) self.assertIn("name", availability) self.assertIn("gid", availability) @@ -78,26 +78,28 @@ def test_wharton_availability(self): # @mock.patch("gsr_booking.api_wrapper.WhartonBookingWrapper.check_credits", mock_check_credits) @mock.patch("gsr_booking.api_wrapper.WhartonBookingWrapper.request", mock_requests_get) - def test_book_wharton(self): + def test_book_wharton(self) -> None: book_wharton = GSRBooker.book_room( 1, 94, "241", "2021-12-05T16:00:00-05:00", "2021-12-05T16:30:00-05:00", self.user ) - self.assertEquals("241", book_wharton.gsrbooking_set.first().room_name) + first_booking = book_wharton.gsrbooking_set.first() + assert first_booking is not None + self.assertEquals("241", first_booking.room_name) @mock.patch("gsr_booking.api_wrapper.WhartonBookingWrapper.request", mock_requests_get) - def test_wharton_reservations(self): + def test_wharton_reservations(self) -> None: reservations = WhartonGSRBooker.get_reservations(self.user) self.assertTrue(isinstance(reservations, list)) self.assertIn("booking_id", reservations[0]) self.assertIn("gid", reservations[0]) @mock.patch("gsr_booking.api_wrapper.WhartonBookingWrapper.request", mock_requests_get) - def test_cancel_wharton(self): + def test_cancel_wharton(self) -> None: cancel = GSRBooker.cancel_room("987654", self.user) self.assertIsNone(cancel) @mock.patch("gsr_booking.api_wrapper.LibCalBookingWrapper.request", mock_requests_get) - def test_libcal_availability(self): + def test_libcal_availability(self) -> None: availability = GSRBooker.get_availability( "1086", 1889, "2021-01-07", "2022-01-08", self.user ) @@ -109,7 +111,7 @@ def test_libcal_availability(self): self.assertIn("availability", availability["rooms"][0]) @mock.patch("gsr_booking.api_wrapper.LibCalBookingWrapper.request", mock_requests_get) - def test_book_libcal(self): + def test_book_libcal(self) -> None: book_libcal = GSRBooker.book_room( 1889, 7192, @@ -118,19 +120,21 @@ def test_book_libcal(self): "2021-12-05T16:30:00-05:00", self.user, ) - self.assertEquals("VP WIC Booth 01", book_libcal.gsrbooking_set.first().room_name) + first_booking = book_libcal.gsrbooking_set.first() + assert first_booking is not None + self.assertEquals("VP WIC Booth 01", first_booking.room_name) @mock.patch( "gsr_booking.api_wrapper.WhartonBookingWrapper.request", mock_requests_get ) # purposefully wharton request here - def test_libcal_reservations(self): + def test_libcal_reservations(self) -> None: reservations = GSRBooker.get_reservations(self.user) self.assertTrue(isinstance(reservations, list)) self.assertIn("booking_id", reservations[0]) self.assertIn("gsr", reservations[0]) @mock.patch("gsr_booking.api_wrapper.LibCalBookingWrapper.request", mock_requests_get) - def test_cancel_libcal(self): + def test_cancel_libcal(self) -> None: group = Group.objects.create(owner=self.user) reservation = Reservation.objects.create(creator=self.user, group=group) GSRBooking.objects.create( @@ -145,10 +149,11 @@ def test_cancel_libcal(self): self.assertIsNone(cancel) @mock.patch("gsr_booking.api_wrapper.WhartonBookingWrapper.request", mock_requests_get) - def test_group_book_wharton(self): + def test_group_book_wharton(self) -> None: # make sure group_user is treated as a wharton user so they # are returned in list of wharton users in gb.book_room membership1 = GroupMembership.objects.filter(group=self.group).first() + assert membership1 is not None membership1.is_wharton = True membership1.save() @@ -180,7 +185,7 @@ def test_group_book_wharton(self): self.assertIsNotNone(Reservation.objects.get(pk=reservation.id)) @mock.patch("gsr_booking.api_wrapper.LibCalBookingWrapper.request", mock_requests_get) - def test_group_book_libcal(self): + def test_group_book_libcal(self) -> None: # add user to the group GroupMembership.objects.create(user=self.user, group=self.group, accepted=True) @@ -211,7 +216,9 @@ def test_group_book_libcal(self): self.assertEqual(len(res), 1) self.assertEqual(res[0]["room_name"], "[Me] VP WIC Booth 01") - credit_owner = reservation.gsrbooking_set.first().user + first_booking = reservation.gsrbooking_set.first() + assert first_booking is not None + credit_owner = first_booking.user res = GSRBooker.get_reservations(credit_owner, self.group) self.assertEqual(len(res), 1) self.assertEqual( @@ -220,7 +227,7 @@ def test_group_book_libcal(self): ) @mock.patch("gsr_booking.api_wrapper.WhartonBookingWrapper.request", mock_requests_get) - def test_group_wharton_availability(self): + def test_group_wharton_availability(self) -> None: with self.assertRaises(APIError): GSRBooker.get_availability( "JMHH", 1, "2021-01-07", "2022-01-08", self.group_user, self.group @@ -234,6 +241,7 @@ def test_group_wharton_availability(self): self.assertIn("name", availability) self.assertIn("gid", availability) self.assertIn("rooms", availability) - self.assertIn("room_name", availability["rooms"][0]) - self.assertIn("id", availability["rooms"][0]) - self.assertIn("availability", availability["rooms"][0]) + room_info = cast(dict[str, Any], availability["rooms"][0]) + self.assertIn("room_name", room_info) + self.assertIn("id", room_info) + self.assertIn("availability", room_info) diff --git a/backend/tests/laundry/test_api_wrapper.py b/backend/tests/laundry/test_api_wrapper.py index dccca9ea..a5749879 100644 --- a/backend/tests/laundry/test_api_wrapper.py +++ b/backend/tests/laundry/test_api_wrapper.py @@ -13,7 +13,7 @@ @mock.patch("requests.get", fakeLaundryGet) class TestAllStatus(TestCase): - def setUp(self): + def setUp(self) -> None: LaundryRoom.objects.get_or_create( hall_id=0, name="Bishop White", location="Quad", total_washers=9, total_dryers=9 ) @@ -27,7 +27,7 @@ def setUp(self): hall_id=3, name="Craig", location="Quad", total_washers=3, total_dryers=3 ) - def test_all_status(self): + def test_all_status(self) -> None: data = all_status() @@ -56,7 +56,7 @@ def test_all_status(self): @mock.patch("requests.get", fakeLaundryGet) class TestHallStatus(TestCase): - def setUp(self): + def setUp(self) -> None: LaundryRoom.objects.get_or_create( hall_id=0, name="Bishop White", location="Quad", total_washers=9, total_dryers=9 ) @@ -70,7 +70,7 @@ def setUp(self): hall_id=3, name="Craig", location="Quad", total_washers=3, total_dryers=3 ) - def test_all_status(self): + def test_all_status(self) -> None: for room in LaundryRoom.objects.all(): @@ -88,7 +88,7 @@ def test_all_status(self): @mock.patch("requests.get", fakeLaundryGet) class TestSaveData(TestCase): - def setUp(self): + def setUp(self) -> None: LaundryRoom.objects.get_or_create( hall_id=0, name="Bishop White", location="Quad", total_washers=9, total_dryers=9 ) @@ -102,7 +102,7 @@ def setUp(self): hall_id=3, name="Craig", location="Quad", total_washers=3, total_dryers=3 ) - def test_save_data(self): + def test_save_data(self) -> None: self.assertEqual(LaundrySnapshot.objects.all().count(), 0) diff --git a/backend/tests/laundry/test_commands.py b/backend/tests/laundry/test_commands.py index 79028394..998a3758 100644 --- a/backend/tests/laundry/test_commands.py +++ b/backend/tests/laundry/test_commands.py @@ -1,7 +1,9 @@ import csv from io import StringIO +from typing import Any from unittest import mock +import requests from django.conf import settings from django.core.management import call_command from django.test import TestCase @@ -9,7 +11,7 @@ from laundry.models import LaundryRoom, LaundrySnapshot -def fakeLaundryGet(url, *args, **kwargs): +def fakeLaundryGet(url: str, *args: Any, **kwargs: Any) -> requests.models.Response: if settings.LAUNDRY_URL in url: with open("tests/laundry/laundry_snapshot.html", "rb") as f: m = mock.MagicMock(content=f.read()) @@ -20,7 +22,7 @@ def fakeLaundryGet(url, *args, **kwargs): @mock.patch("requests.get", fakeLaundryGet) class TestGetSnapshot(TestCase): - def setUp(self): + def setUp(self) -> None: # populates database with LaundryRooms LaundryRoom.objects.get_or_create( hall_id=0, name="Bishop White", location="Quad", total_washers=9, total_dryers=9 @@ -35,7 +37,7 @@ def setUp(self): hall_id=3, name="Craig", location="Quad", total_washers=3, total_dryers=3 ) - def test_db_populate(self): + def test_db_populate(self) -> None: out = StringIO() call_command("get_snapshot", stdout=out) @@ -48,7 +50,7 @@ def test_db_populate(self): @mock.patch("requests.get", fakeLaundryGet) class TestLaundryRoomMigration(TestCase): - def test_db_populate(self): + def test_db_populate(self) -> None: out = StringIO() call_command("load_laundry_rooms", stdout=out) diff --git a/backend/tests/laundry/test_models.py b/backend/tests/laundry/test_models.py index 5dbd8fbc..bf2fa08b 100644 --- a/backend/tests/laundry/test_models.py +++ b/backend/tests/laundry/test_models.py @@ -4,7 +4,7 @@ class LaundrySnapshotTestCase(TestCase): - def setUp(self): + def setUp(self) -> None: # populates database with LaundryRooms LaundryRoom.objects.get_or_create( hall_id=0, name="Bishop White", location="Quad", total_washers=9, total_dryers=9 @@ -23,15 +23,15 @@ def setUp(self): room=self.laundry_room, available_washers=10, available_dryers=10 ) - def test_str(self): + def test_str(self) -> None: self.assertEqual( str(self.snapshot), - f"Hall No. {self.snapshot.room.hall_id} | {self.snapshot.date.date()}", + f"Hall No. {self.snapshot.room.hall_id} | {self.snapshot.date.date()}", # ignore: type ) class LaundryRoomTestCase(TestCase): - def setUp(self): + def setUp(self) -> None: # populates database with LaundryRooms LaundryRoom.objects.get_or_create( hall_id=0, name="Bishop White", location="Quad", total_washers=9, total_dryers=9 @@ -47,5 +47,5 @@ def setUp(self): ) self.room = LaundryRoom.objects.create(hall_id=1, name="test hall", location="location") - def test_str(self): + def test_str(self) -> None: self.assertEqual(str(self.room), f"Hall No. {self.room.hall_id} | {self.room.name}") diff --git a/backend/tests/laundry/test_views.py b/backend/tests/laundry/test_views.py index d0dae554..78249de9 100644 --- a/backend/tests/laundry/test_views.py +++ b/backend/tests/laundry/test_views.py @@ -1,7 +1,6 @@ import json from unittest import mock -from django.contrib.auth import get_user_model from django.test import TestCase from django.urls import reverse from django.utils import timezone @@ -9,14 +8,12 @@ from laundry.models import LaundryRoom, LaundrySnapshot from tests.laundry.test_commands import fakeLaundryGet - - -User = get_user_model() +from utils.types import DjangoUserModel, UserType @mock.patch("requests.get", fakeLaundryGet) class HallIdViewTestCase(TestCase): - def setUp(self): + def setUp(self) -> None: LaundryRoom.objects.get_or_create( hall_id=0, name="Bishop White", location="Quad", total_washers=9, total_dryers=9 ) @@ -29,9 +26,9 @@ def setUp(self): LaundryRoom.objects.get_or_create( hall_id=3, name="Craig", location="Quad", total_washers=3, total_dryers=3 ) - self.client = APIClient() + self.client: APIClient = APIClient() - def test_response(self): + def test_response(self) -> None: response = self.client.get(reverse("hall-ids")) res_json = json.loads(response.content) for hall in res_json: @@ -44,7 +41,7 @@ def test_response(self): @mock.patch("requests.get", fakeLaundryGet) class HallInfoViewTestCase(TestCase): - def setUp(self): + def setUp(self) -> None: LaundryRoom.objects.get_or_create( hall_id=0, name="Bishop White", location="Quad", total_washers=9, total_dryers=9 ) @@ -58,9 +55,9 @@ def setUp(self): hall_id=3, name="Craig", location="Quad", total_washers=3, total_dryers=3 ) self.laundry_room = LaundryRoom.objects.get(hall_id=0, name="Bishop White", location="Quad") - self.client = APIClient() + self.client: APIClient = APIClient() - def test_response(self): + def test_response(self) -> None: response = self.client.get(reverse("hall-info", args=[self.laundry_room.hall_id])) res_json = json.loads(response.content) if response.status_code == 200: @@ -69,14 +66,14 @@ def test_response(self): elif response.status_code == 503: self.assertEqual("The laundry api is currently unavailable.", res_json["error"]) - def test_hall_error(self): + def test_hall_error(self) -> None: response = self.client.get(reverse("hall-info", args=[1000000])) self.assertEqual(404, response.status_code) @mock.patch("requests.get", fakeLaundryGet) class MultipleHallInfoViewTestCase(TestCase): - def setUp(self): + def setUp(self) -> None: LaundryRoom.objects.get_or_create( hall_id=0, name="Bishop White", location="Quad", total_washers=9, total_dryers=9 ) @@ -90,9 +87,9 @@ def setUp(self): hall_id=3, name="Craig", location="Quad", total_washers=3, total_dryers=3 ) self.laundry_room = LaundryRoom.objects.get(hall_id=0, name="Bishop White", location="Quad") - self.client = APIClient() + self.client: APIClient = APIClient() - def test_response(self): + def test_response(self) -> None: response = self.client.get(reverse("multiple-hall-info", args=["0,1,2,3"])) res_json = json.loads(response.content) if response.status_code == 200: @@ -111,14 +108,14 @@ def test_response(self): elif response.status_code == 503: self.assertEqual("The laundry api is currently unavailable.", res_json["error"]) - def test_hall_error(self): + def test_hall_error(self) -> None: response = self.client.get(reverse("multiple-hall-info", args=["1000000"])) self.assertEqual(404, response.status_code) @mock.patch("requests.get", fakeLaundryGet) class HallUsageViewTestCase(TestCase): - def setUp(self): + def setUp(self) -> None: LaundryRoom.objects.get_or_create( hall_id=0, name="Bishop White", location="Quad", total_washers=9, total_dryers=9 ) @@ -135,9 +132,9 @@ def setUp(self): self.snapshot = LaundrySnapshot.objects.create( room=self.laundry_room, available_washers=5, available_dryers=10 ) - self.client = APIClient() + self.client: APIClient = APIClient() - def test_response(self): + def test_response(self) -> None: response = self.client.get(reverse("hall-usage", args=[self.laundry_room.hall_id])) res_json = json.loads(response.content) @@ -150,7 +147,7 @@ def test_response(self): @mock.patch("requests.get", fakeLaundryGet) class PreferencesTestCase(TestCase): - def setUp(self): + def setUp(self) -> None: LaundryRoom.objects.get_or_create( hall_id=0, name="Bishop White", location="Quad", total_washers=9, total_dryers=9 ) @@ -163,22 +160,22 @@ def setUp(self): LaundryRoom.objects.get_or_create( hall_id=3, name="Craig", location="Quad", total_washers=3, total_dryers=3 ) - self.client = APIClient() - self.test_user = User.objects.create_user("user", "user@a.com", "user") + self.client: APIClient = APIClient() + self.test_user: UserType = DjangoUserModel.objects.create_user("user", "user@a.com", "user") self.laundry_room = LaundryRoom.objects.get(hall_id=0, name="Bishop White", location="Quad") self.other_laundry_room = LaundryRoom.objects.get( hall_id=1, name="Chestnut Butcher", location="Quad" ) self.test_user.profile.laundry_preferences.add(self.laundry_room) - def test_get(self): + def test_get(self) -> None: self.client.force_authenticate(user=self.test_user) response = self.client.get(reverse("preferences")) res_json = json.loads(response.content) self.assertIn(self.laundry_room.hall_id, res_json["rooms"]) - def test_post(self): + def test_post(self) -> None: self.client.force_authenticate(user=self.test_user) self.client.post(reverse("preferences"), {"rooms": [self.other_laundry_room.hall_id]}) diff --git a/backend/tests/penndata/test_views.py b/backend/tests/penndata/test_views.py index fea551a7..67795636 100644 --- a/backend/tests/penndata/test_views.py +++ b/backend/tests/penndata/test_views.py @@ -1,8 +1,8 @@ import datetime import json +from typing import Any from unittest import mock -from django.contrib.auth import get_user_model from django.core.management import call_command from django.test import TestCase from django.urls import reverse @@ -13,9 +13,10 @@ from laundry.models import LaundryRoom from penndata.models import AnalyticsEvent, Event, FitnessRoom, FitnessSnapshot from portal.models import Poll, Post +from utils.types import DjangoUserModel, UserType -def fakeFitnessGet(url, *args, **kwargs): +def fakeFitnessGet(url: str, *args: Any, **kwargs: Any) -> mock.MagicMock: if "docs.google.com/spreadsheets/" in url: with open("tests/penndata/fitness_snapshot.html", "rb") as f: m = mock.MagicMock(content=f.read()) @@ -24,11 +25,8 @@ def fakeFitnessGet(url, *args, **kwargs): raise NotImplementedError -User = get_user_model() - - class TestNews(TestCase): - def test_response(self): + def test_response(self) -> None: response = self.client.get(reverse("news")) res_json = json.loads(response.content) self.assertEqual(len(res_json), 6) @@ -40,10 +38,10 @@ def test_response(self): class TestCalender(TestCase): - def setUp(self): + def setUp(self) -> None: call_command("get_calendar") - def test_response(self): + def test_response(self) -> None: response = self.client.get(reverse("calendar")) res_json = json.loads(response.content) @@ -54,8 +52,8 @@ def test_response(self): class TestEvent(TestCase): - def setUp(self): - self.client = APIClient() + def setUp(self) -> None: + self.client: APIClient = APIClient() self.event1 = Event.objects.create( event_type="type1", name="Event 1", @@ -75,7 +73,7 @@ def setUp(self): website="https://pennlabs.org/", ) - def test_get_all_events(self): + def test_get_all_events(self) -> None: """Test GET request to retrieve all events (no type)""" url = reverse("events") response = self.client.get(url) @@ -83,7 +81,7 @@ def test_get_all_events(self): res_json = json.loads(response.content) self.assertEqual(len(events), len(res_json)) - def test_get_events_by_type(self): + def test_get_events_by_type(self) -> None: """Test GET request to retrieve events by type""" url = reverse("events-type", kwargs={"type": "type1"}) response = self.client.get(url) @@ -95,13 +93,13 @@ def test_get_events_by_type(self): class TestHomePage(TestCase): - def setUp(self): + def setUp(self) -> None: call_command("load_venues") call_command("load_laundry_rooms") - self.client = APIClient() - self.test_user = User.objects.create_user("user", "user@a.com", "user") + self.client: APIClient = APIClient() + self.test_user: UserType = DjangoUserModel.objects.create_user("user", "user@a.com", "user") - def test_first_response(self): + def test_first_response(self) -> None: self.client.force_authenticate(user=self.test_user) response = self.client.get(reverse("homepage")) res_json = json.loads(response.content)["cells"] @@ -138,10 +136,10 @@ def test_first_response(self): class TestGetRecentFitness(TestCase): - def setUp(self): + def setUp(self) -> None: call_command("load_fitness_rooms") - self.client = APIClient() - self.test_user = User.objects.create_user("user", "user@a.com", "user") + self.client: APIClient = APIClient() + self.test_user: UserType = DjangoUserModel.objects.create_user("user", "user@a.com", "user") self.client.force_authenticate(user=self.test_user) self.fitness_room = FitnessRoom.objects.first() @@ -156,13 +154,14 @@ def setUp(self): room=self.fitness_room, date=self.new_time, count=self.new_count ) - def test_get_recent(self): + def test_get_recent(self) -> None: response = self.client.get(reverse("fitness")) res_json = json.loads(response.content) for room_obj in res_json: room_obj.pop("open") room_obj.pop("close") + assert self.fitness_room is not None expected = [ { "id": room.id, @@ -196,10 +195,10 @@ def test_get_recent(self): @mock.patch("requests.get", fakeFitnessGet) class TestGetFitnessSnapshot(TestCase): - def setUp(self): + def setUp(self) -> None: call_command("load_fitness_rooms") - def test_get_fitness_snapshot(self): + def test_get_fitness_snapshot(self) -> None: self.assertEqual(FitnessSnapshot.objects.all().count(), 0) call_command("get_fitness_snapshot") @@ -219,7 +218,7 @@ def test_get_fitness_snapshot(self): class TestFitnessUsage(TestCase): - def load_snapshots_1(self, date): + def load_snapshots_1(self, date: datetime.datetime) -> None: # 6:00, 0 FitnessSnapshot.objects.create( room=self.room, date=date + datetime.timedelta(hours=6), count=0, capacity=0.0 @@ -254,7 +253,7 @@ def load_snapshots_1(self, date): capacity=0.0, ) - def load_snapshots_2(self, date): + def load_snapshots_2(self, date: datetime.datetime) -> None: # 7:30, 3 FitnessSnapshot.objects.create( room=self.room, @@ -270,12 +269,12 @@ def load_snapshots_2(self, date): capacity=93.0, ) - def setUp(self): - self.client = APIClient() + def setUp(self) -> None: + self.client: APIClient = APIClient() self.date = timezone.make_aware(datetime.datetime(2023, 1, 19, 0, 0, 0)) self.room = FitnessRoom.objects.create(name="test") - def test_get_fitness_usage_1(self): + def test_get_fitness_usage_1(self) -> None: self.load_snapshots_1(self.date) response = self.client.get( reverse("fitness-usage", args=[self.room.id]), @@ -319,7 +318,7 @@ def test_get_fitness_usage_1(self): self.assertEqual(res_json, expected) - def test_get_fitness_usage_2(self): + def test_get_fitness_usage_2(self) -> None: self.load_snapshots_2(self.date) response = self.client.get( reverse("fitness-usage", args=[self.room.id]), @@ -362,14 +361,14 @@ def test_get_fitness_usage_2(self): self.assertEqual(res_json, expected) - def test_get_usage_2_samples_week(self): + def test_get_usage_2_samples_week(self) -> None: self.load_snapshots_1(self.date) self.load_snapshots_2(self.date - datetime.timedelta(days=7)) response = self.client.get( reverse("fitness-usage", args=[self.room.id]), { "date": (self.date).strftime("%Y-%m-%d"), - "num_samples": 2, + "num_samples": "2", "group_by": "week", "field": "capacity", }, @@ -438,12 +437,12 @@ def test_get_usage_2_samples_week(self): self.assertEqual(res_json, expected) - def test_get_usage_2_samples_day(self): + def test_get_usage_2_samples_day(self) -> None: self.load_snapshots_2(self.date) self.load_snapshots_1(self.date - datetime.timedelta(days=1)) response = self.client.get( reverse("fitness-usage", args=[self.room.id]), - {"date": (self.date).strftime("%Y-%m-%d"), "num_samples": 2}, + {"date": (self.date).strftime("%Y-%m-%d"), "num_samples": "2"}, ) res_json = json.loads(response.content) @@ -510,7 +509,7 @@ def test_get_usage_2_samples_day(self): self.assertEqual(res_json, expected) - def test_day_closed(self): + def test_day_closed(self) -> None: self.load_snapshots_1(self.date - datetime.timedelta(days=1)) response = self.client.get( reverse("fitness-usage", args=[self.room.id]), @@ -565,7 +564,7 @@ def test_day_closed(self): } self.assertEqual(res_json, expected) - def test_get_fitness_usage_error(self): + def test_get_fitness_usage_error(self) -> None: response = self.client.get(reverse("fitness-usage", args=[self.room.id + 1])) self.assertEqual(response.status_code, 404) @@ -576,25 +575,25 @@ def test_get_fitness_usage_error(self): @mock.patch("requests.get", fakeFitnessGet) class FitnessPreferencesTestCase(TestCase): - def setUp(self): + def setUp(self) -> None: FitnessRoom.objects.get_or_create(id=0, name="1st Floor Fitness") FitnessRoom.objects.get_or_create(id=1, name="MPR") FitnessRoom.objects.get_or_create(id=2, name="Pool-Deep") FitnessRoom.objects.get_or_create(id=3, name="4th Floor Fitness") - self.client = APIClient() - self.test_user = User.objects.create_user("user", "user@a.com", "user") + self.client: APIClient = APIClient() + self.test_user: UserType = DjangoUserModel.objects.create_user("user", "user@a.com", "user") self.fitness_room = FitnessRoom.objects.get(id=0, name="1st Floor Fitness") self.other_fitness_room = FitnessRoom.objects.get(id=1, name="MPR") self.test_user.profile.fitness_preferences.add(self.fitness_room) - def test_get(self): + def test_get(self) -> None: self.client.force_authenticate(user=self.test_user) response = self.client.get(reverse("fitness-preferences")) res_json = json.loads(response.content) self.assertIn(self.fitness_room.id, res_json["rooms"]) - def test_post(self): + def test_post(self) -> None: self.client.force_authenticate(user=self.test_user) self.client.post(reverse("fitness-preferences"), {"rooms": [self.other_fitness_room.id]}) @@ -605,12 +604,12 @@ def test_post(self): class TestAnalytics(TestCase): - def setUp(self): - self.client = APIClient() - self.test_user = User.objects.create_user("user", "user@a.com", "user") + def setUp(self) -> None: + self.client: APIClient = APIClient() + self.test_user: UserType = DjangoUserModel.objects.create_user("user", "user@a.com", "user") self.client.force_authenticate(user=self.test_user) - def test_create_regular_analytics(self): + def test_create_regular_analytics(self) -> None: payload = { "cell_type": "dining", "index": 0, @@ -624,7 +623,7 @@ def test_create_regular_analytics(self): self.assertIsNone(res_json["post"]) self.assertIsNone(res_json["poll"]) - def test_create_poll_analytics(self): + def test_create_poll_analytics(self) -> None: poll = Poll.objects.create( club_code="pennlabs", question="hello?", @@ -645,7 +644,7 @@ def test_create_poll_analytics(self): self.assertIsNone(res_json["post"]) self.assertTrue(res_json["is_interaction"]) - def test_create_post_analytics(self): + def test_create_post_analytics(self) -> None: post = Post.objects.create( club_code="notpennlabs", title="Test title 2", @@ -667,7 +666,7 @@ def test_create_post_analytics(self): self.assertIsNotNone(res_json["post"]) self.assertFalse(res_json["is_interaction"]) - def test_fail_post_poll_analytics(self): + def test_fail_post_poll_analytics(self) -> None: poll = Poll.objects.create( club_code="pennlabs", question="hello?", @@ -693,12 +692,12 @@ def test_fail_post_poll_analytics(self): class TestUniqueCounterView(TestCase): - def setUp(self): - self.client = APIClient() - self.test_user = User.objects.create_user("user", "user@a.com", "user") + def setUp(self) -> None: + self.client: APIClient = APIClient() + self.test_user: UserType = DjangoUserModel.objects.create_user("user", "user@a.com", "user") self.client.force_authenticate(user=self.test_user) - def test_get_unique_counter(self): + def test_get_unique_counter(self) -> None: post = Post.objects.create( club_code="pennlabs", title="Test title", @@ -728,6 +727,6 @@ def test_get_unique_counter(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["count"], 0) - def test_get_unique_counter_no_id(self): + def test_get_unique_counter_no_id(self) -> None: response = self.client.get(reverse("eventcounter")) self.assertEqual(response.status_code, 400) diff --git a/backend/tests/portal/test_permissions.py b/backend/tests/portal/test_permissions.py index a3081b76..b0bee144 100644 --- a/backend/tests/portal/test_permissions.py +++ b/backend/tests/portal/test_permissions.py @@ -1,8 +1,8 @@ import datetime import json +from typing import Any from unittest import mock -from django.contrib.auth import get_user_model from django.core.management import call_command from django.test import TestCase from django.urls import reverse @@ -10,55 +10,60 @@ from rest_framework.test import APIClient from portal.models import Poll, PollOption, PollVote +from utils.types import DjangoUserModel, UserType -User = get_user_model() - - -def mock_get_user_clubs(*args, **kwargs): +def mock_get_user_clubs(*args: Any, **kwargs: Any) -> list[dict[str, Any]]: with open("tests/portal/get_user_clubs.json") as data: return json.load(data) class PollPermissions(TestCase): - def setUp(self): + def setUp(self) -> None: call_command("load_target_populations", "--years", "2022, 2023, 2024, 2025") - self.client = APIClient() - self.admin = User.objects.create_superuser("admin@example.com", "admin", "admin") - self.user1 = User.objects.create_user("user1", "user@seas.upenn.edu", "user") - self.user2 = User.objects.create_user("user2", "user@seas.upenn.edu", "user") + self.client: APIClient = APIClient() + self.admin: UserType = DjangoUserModel.objects.create_superuser( + "admin@example.com", "admin", "admin" + ) + self.user1: UserType = DjangoUserModel.objects.create_user( + "user1", "user@seas.upenn.edu", "user" + ) + self.user2: UserType = DjangoUserModel.objects.create_user( + "user2", "user@seas.upenn.edu", "user" + ) - self.poll_1 = Poll.objects.create( + self.poll_1: Poll = Poll.objects.create( club_code="pennlabs", question="poll question 1", expire_date=timezone.now() + datetime.timedelta(days=1), status=Poll.STATUS_APPROVED, ) - self.poll_option_1 = PollOption.objects.create(poll=self.poll_1, choice="hello!") - self.poll_option_2 = PollOption.objects.create(poll=self.poll_1, choice="hello!!!!") - self.poll_option_3 = PollOption.objects.create(poll=self.poll_1, choice="hello!!!!!!!") + self.poll_option_1: PollOption = PollOption.objects.create( + poll=self.poll_1, choice="hello!" + ) + self.poll_option_2: PollOption = PollOption.objects.create( + poll=self.poll_1, choice="hello!!!!" + ) + self.poll_option_3: PollOption = PollOption.objects.create( + poll=self.poll_1, choice="hello!!!!!!!" + ) - self.poll_2 = Poll.objects.create( + self.poll_2: Poll = Poll.objects.create( club_code="pennlabs", question="poll question 2", expire_date=timezone.now() + datetime.timedelta(days=1), status=Poll.STATUS_APPROVED, ) - self.poll_vote = PollVote.objects.create(id_hash="2", poll=self.poll_1) + self.poll_vote: PollVote = PollVote.objects.create(id_hash="2", poll=self.poll_1) self.poll_vote.poll_options.add(self.poll_option_1) @mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs) - def test_authentication(self): + def test_authentication(self) -> None: # asserts that anonymous users cannot access any route - list_urls = [ - "poll-list", - "polloption-list", - "pollvote-list", - "target-populations", - ] + list_urls = ["poll-list", "polloption-list", "pollvote-list", "target-populations"] for url in list_urls: response_1 = self.client.get(reverse(f"portal:{url}")) self.assertEqual(response_1.status_code, 403) @@ -70,7 +75,7 @@ def test_authentication(self): @mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.views.get_user_clubs", mock_get_user_clubs) - def test_update_poll(self): + def test_update_poll(self) -> None: # users in same club can edit self.client.force_authenticate(user=self.user2) payload_1 = {"status": Poll.STATUS_REVISION} @@ -90,7 +95,7 @@ def test_update_poll(self): @mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.views.get_user_clubs", mock_get_user_clubs) - def test_create_update_options(self): + def test_create_update_options(self) -> None: # users in same club can edit poll option self.client.force_authenticate(user=self.user2) payload_1 = {"poll": self.poll_1.id, "choice": "hello"} diff --git a/backend/tests/portal/test_polls.py b/backend/tests/portal/test_polls.py index 1002d803..5d67be25 100644 --- a/backend/tests/portal/test_polls.py +++ b/backend/tests/portal/test_polls.py @@ -1,8 +1,8 @@ import datetime import json +from typing import Any from unittest import mock -from django.contrib.auth import get_user_model from django.core.management import call_command from django.test import TestCase from django.utils import timezone @@ -10,27 +10,25 @@ from portal.models import Poll, PollOption, PollVote, TargetPopulation from utils.email import get_backend_manager_emails +from utils.types import DjangoUserModel, UserType -User = get_user_model() - - -def mock_get_user_clubs(*args, **kwargs): +def mock_get_user_clubs(*args: Any, **kwargs: Any) -> list[dict[str, Any]]: with open("tests/portal/get_user_clubs.json") as data: return json.load(data) -def mock_get_user_info(*args, **kwargs): +def mock_get_user_info(*args: Any, **kwargs: Any) -> dict[str, Any]: with open("tests/portal/get_user_info.json") as data: return json.load(data) -def mock_get_null_user_info(*args, **kwargs): +def mock_get_null_user_info(*args: Any, **kwargs: Any) -> dict[str, Any]: with open("tests/portal/get_null_user_info.json") as data: return json.load(data) -def mock_get_club_info(*args, **kwargs): +def mock_get_club_info(*args: Any, **kwargs: Any) -> dict[str, Any]: with open("tests/portal/get_club_info.json") as data: return json.load(data) @@ -38,22 +36,28 @@ def mock_get_club_info(*args, **kwargs): class TestUserClubs(TestCase): """Test User and Club information""" - def setUp(self): - self.client = APIClient() - self.test_user = User.objects.create_user("user", "user@seas.upenn.edu", "user") + def setUp(self) -> None: + self.client: APIClient = APIClient() + self.test_user: UserType = DjangoUserModel.objects.create_user( + "user", "user@seas.upenn.edu", "user" + ) self.client.force_authenticate(user=self.test_user) @mock.patch("portal.views.get_user_info", mock_get_user_info) - def test_user_info(self): + def test_user_info(self) -> None: response = self.client.get("/portal/user/") res_json = json.loads(response.content) + assert isinstance(res_json, dict) + assert isinstance(res_json["user"], dict) self.assertEqual(12345678, res_json["user"]["pennid"]) @mock.patch("portal.views.get_club_info", mock_get_club_info) @mock.patch("portal.views.get_user_clubs", mock_get_user_clubs) - def test_user_clubs(self): + def test_user_clubs(self) -> None: response = self.client.get("/portal/clubs/") res_json = json.loads(response.content) + assert isinstance(res_json, dict) + assert isinstance(res_json["clubs"], list) self.assertEqual("pennlabs", res_json["clubs"][0]["code"]) @@ -61,17 +65,21 @@ class TestPolls(TestCase): """Tests Create/Update/Retrieve for Polls and Poll Options""" @mock.patch("portal.serializers.get_user_clubs", mock_get_user_clubs) - def setUp(self): + def setUp(self) -> None: call_command("load_target_populations", "--years", "2022, 2023, 2024, 2025") - self.target_id = TargetPopulation.objects.get(population="2024").id - year = TargetPopulation.objects.get(population="2024").id + target = TargetPopulation.objects.get(population="2024") + self.target_id = target.id + year = target.id major = TargetPopulation.objects.get(population="Computer Science, BSE").id school = TargetPopulation.objects.get( population="School of Engineering and Applied Science" ).id degree = TargetPopulation.objects.get(population="BACHELORS").id - self.client = APIClient() - self.test_user = User.objects.create_user("user", "user@seas.upenn.edu", "user") + + self.client: APIClient = APIClient() + self.test_user: UserType = DjangoUserModel.objects.create_user( + "user", "user@seas.upenn.edu", "user" + ) self.client.force_authenticate(user=self.test_user) # creates an approved poll to work with payload = { @@ -98,10 +106,10 @@ def setUp(self): poll.save() poll_1 = Poll.objects.get(question="How is your day") - self.id = poll_1.id + self.poll_id = poll_1.id @mock.patch("portal.serializers.get_user_clubs", mock_get_user_clubs) - def test_create_poll(self): + def test_create_poll(self) -> None: # creates an unapproved poll payload = { "club_code": "pennlabs", @@ -119,19 +127,17 @@ def test_create_poll(self): @mock.patch("portal.views.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs) - def test_update_poll(self): - payload = { - "question": "New question", - } - response = self.client.patch(f"/portal/polls/{self.id}/", payload) + def test_update_poll(self) -> None: + payload = {"question": "New question"} + response = self.client.patch(f"/portal/polls/{self.poll_id}/", payload) res_json = json.loads(response.content) # asserts that the update worked - self.assertEqual(self.id, res_json["id"]) - self.assertEqual("New question", Poll.objects.get(id=self.id).question) + self.assertEqual(self.poll_id, res_json["id"]) + self.assertEqual("New question", Poll.objects.get(id=self.poll_id).question) @mock.patch("portal.serializers.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.logic.get_user_info", mock_get_user_info) - def test_browse(self): + def test_browse(self) -> None: payload = { "club_code": "pennlabs", "question": "How is this question? 2", @@ -148,7 +154,7 @@ def test_browse(self): @mock.patch("portal.serializers.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.logic.get_user_info", mock_get_null_user_info) - def test_null_user_info_browse(self): + def test_null_user_info_browse(self) -> None: # Asserts that a user with empty user info can access all available polls response = self.client.post("/portal/polls/browse/", {"id_hash": 1}) res_json = json.loads(response.content) @@ -158,38 +164,38 @@ def test_null_user_info_browse(self): @mock.patch("portal.serializers.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.logic.get_user_info", mock_get_user_info) - def test_create_option(self): - payload_1 = {"poll": self.id, "choice": "yes!"} - payload_2 = {"poll": self.id, "choice": "no!"} + def test_create_option(self) -> None: + payload_1 = {"poll": self.poll_id, "choice": "yes!"} + payload_2 = {"poll": self.poll_id, "choice": "no!"} self.client.post("/portal/options/", payload_1) self.client.post("/portal/options/", payload_2) self.assertEqual(2, PollOption.objects.all().count()) # asserts options were created and were placed to right poll for poll_option in PollOption.objects.all(): - self.assertEqual(Poll.objects.get(id=self.id), poll_option.poll) + self.assertEqual(Poll.objects.get(id=self.poll_id), poll_option.poll) response = self.client.post("/portal/polls/browse/", {"id_hash": 1}) res_json = json.loads(response.content) self.assertEqual(2, len(res_json[0]["options"])) @mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.views.get_user_clubs", mock_get_user_clubs) - def test_update_option(self): - payload_1 = {"poll": self.id, "choice": "yes!"} + def test_update_option(self) -> None: + payload_1 = {"poll": self.poll_id, "choice": "yes!"} response = self.client.post("/portal/options/", payload_1) res_json = json.loads(response.content) self.assertEqual("yes!", PollOption.objects.get(id=res_json["id"]).choice) - payload_2 = {"poll": self.id, "choice": "no!"} + payload_2 = {"poll": self.poll_id, "choice": "no!"} # checks that poll's option was changed self.client.patch(f'/portal/options/{res_json["id"]}/', payload_2) self.assertEqual("no!", PollOption.objects.get(id=res_json["id"]).choice) - def test_review_poll(self): + def test_review_poll(self) -> None: Poll.objects.create( club_code="pennlabs", question="hello?", expire_date=timezone.now() + datetime.timedelta(days=3), ) - admin = User.objects.create_superuser("admin@example.com", "admin", "admin") + admin = DjangoUserModel.objects.create_superuser("admin@example.com", "admin", "admin") self.client.force_authenticate(user=admin) response = self.client.get("/portal/polls/review/") res_json = json.loads(response.content) @@ -200,12 +206,12 @@ def test_review_poll(self): @mock.patch("portal.serializers.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.logic.get_user_info", mock_get_user_info) - def test_more_than_five_options(self): - payload_1 = {"poll": self.id, "choice": "1"} - payload_2 = {"poll": self.id, "choice": "2"} - payload_3 = {"poll": self.id, "choice": "3"} - payload_4 = {"poll": self.id, "choice": "4"} - payload_5 = {"poll": self.id, "choice": "5"} + def test_more_than_five_options(self) -> None: + payload_1 = {"poll": self.poll_id, "choice": "1"} + payload_2 = {"poll": self.poll_id, "choice": "2"} + payload_3 = {"poll": self.poll_id, "choice": "3"} + payload_4 = {"poll": self.poll_id, "choice": "4"} + payload_5 = {"poll": self.poll_id, "choice": "5"} self.client.post("/portal/options/", payload_1) self.client.post("/portal/options/", payload_2) self.client.post("/portal/options/", payload_3) @@ -214,17 +220,17 @@ def test_more_than_five_options(self): self.assertEqual(5, PollOption.objects.all().count()) # asserts options were created and were placed to right poll for poll_option in PollOption.objects.all(): - self.assertEqual(Poll.objects.get(id=self.id), poll_option.poll) + self.assertEqual(Poll.objects.get(id=self.poll_id), poll_option.poll) response = self.client.post("/portal/polls/browse/", {"id_hash": 1}) res_json = json.loads(response.content) self.assertEqual(5, len(res_json[0]["options"])) # adding more than 5 options to same poll should not be allowed - payload_6 = {"poll": self.id, "choice": "6"} + payload_6 = {"poll": self.poll_id, "choice": "6"} response = self.client.post("/portal/options/", payload_6) self.assertEqual(5, PollOption.objects.all().count()) - def test_option_vote_view(self): - response = self.client.get(f"/portal/polls/{self.id}/option_view/") + def test_option_vote_view(self) -> None: + response = self.client.get(f"/portal/polls/{self.poll_id}/option_view/") res_json = json.loads(response.content) self.assertEqual("pennlabs", res_json["club_code"]) # test that options key is in response @@ -233,7 +239,7 @@ def test_option_vote_view(self): @mock.patch("portal.serializers.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs) @mock.patch("utils.email.send_automated_email.delay_on_commit") - def test_send_email_on_create(self, mock_send_email): + def test_send_email_on_create(self, mock_send_email: mock.Mock) -> None: payload = { "club_code": "pennlabs", "question": "How is this question? 2", @@ -249,7 +255,7 @@ def test_send_email_on_create(self, mock_send_email): @mock.patch("portal.serializers.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs) @mock.patch("utils.email.send_automated_email.delay_on_commit") - def test_send_email_on_status_change(self, mock_send_email): + def test_send_email_on_status_change(self, mock_send_email: mock.Mock) -> None: payload = { "club_code": "pennlabs", "question": "How is this question? 2", @@ -262,6 +268,7 @@ def test_send_email_on_status_change(self, mock_send_email): mock_send_email.assert_called_once() poll = Poll.objects.last() + assert poll is not None poll.status = Poll.STATUS_REVISION poll.save() @@ -272,12 +279,14 @@ def test_send_email_on_status_change(self, mock_send_email): class TestPollVotes(TestCase): """Tests Create/Update Polls and History""" - def setUp(self): + def setUp(self) -> None: call_command("load_target_populations", "--years", "2022, 2023, 2024, 2025") self.target_id = TargetPopulation.objects.get(population="2024").id - self.client = APIClient() - self.test_user = User.objects.create_user("user", "user@seas.upenn.edu", "user") + self.client: APIClient = APIClient() + self.test_user: UserType = DjangoUserModel.objects.create_user( + "user", "user@seas.upenn.edu", "user" + ) self.client.force_authenticate(user=self.test_user) # creates 4 polls, each with 3 options @@ -331,27 +340,29 @@ def setUp(self): PollOption.objects.create(poll=p4, choice="choice 12") @mock.patch("portal.logic.get_user_info", mock_get_user_info) - def test_create_vote(self): + def test_create_vote(self) -> None: payload_1 = {"id_hash": 1, "poll_options": [self.p1_op1_id]} response = self.client.post("/portal/votes/", payload_1) res_json = json.loads(response.content) + assert isinstance(res_json, dict) # tests that voting works self.assertIn(self.p1_op1_id, res_json["poll_options"]) + vote = PollVote.objects.first() + assert vote is not None self.assertEqual(1, PollVote.objects.all().count()) - self.assertEqual("1", PollVote.objects.all().first().id_hash) + self.assertEqual("1", vote.id_hash) self.assertIn( - TargetPopulation.objects.get(id=self.target_id), - PollVote.objects.all().first().target_populations.all(), + TargetPopulation.objects.get(id=self.target_id), vote.target_populations.all() ) - def test_recent_poll_empty(self): + def test_recent_poll_empty(self) -> None: response = self.client.post("/portal/votes/recent/", {"id_hash": 1}) res_json = json.loads(response.content) self.assertIsNone(res_json["created_date"]) self.assertIsNone(res_json["poll"]["created_date"]) @mock.patch("portal.logic.get_user_info", mock_get_user_info) - def test_recent_poll(self): + def test_recent_poll(self) -> None: # answer poll payload_1 = {"id_hash": 1, "poll_options": [self.p1_op1_id]} self.client.post("/portal/votes/", payload_1) @@ -368,7 +379,7 @@ def test_recent_poll(self): self.assertEquals(self.p4_id, res_json2["poll"]["id"]) @mock.patch("portal.logic.get_user_info", mock_get_user_info) - def test_all_votes(self): + def test_all_votes(self) -> None: payload_1 = {"id_hash": 1, "poll_options": [self.p1_op1_id]} self.client.post("/portal/votes/", payload_1) payload_2 = {"id_hash": 1, "poll_options": [self.p4_op1_id]} @@ -382,7 +393,7 @@ def test_all_votes(self): @mock.patch("portal.logic.get_user_info", mock_get_user_info) @mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs) - def test_demographic_breakdown(self): + def test_demographic_breakdown(self) -> None: # plugging in votes for breakdown payload_1 = {"id_hash": 1, "poll_options": [self.p1_op1_id]} self.client.post("/portal/votes/", payload_1) diff --git a/backend/tests/portal/test_posts.py b/backend/tests/portal/test_posts.py index 4a2b5bae..8b1cf513 100644 --- a/backend/tests/portal/test_posts.py +++ b/backend/tests/portal/test_posts.py @@ -1,8 +1,8 @@ import datetime import json +from typing import Any, cast from unittest import mock -from django.contrib.auth import get_user_model from django.core.management import call_command from django.test import TestCase from django.utils import timezone @@ -10,26 +10,24 @@ from portal.models import Post, TargetPopulation from utils.email import get_backend_manager_emails +from utils.types import DjangoUserModel, UserType -User = get_user_model() - - -def mock_get_user_clubs(*args, **kwargs): +def mock_get_user_clubs(*args: Any, **kwargs: Any) -> list[dict]: with open("tests/portal/get_user_clubs.json") as data: return json.load(data) -def mock_get_no_clubs(*args, **kwargs): +def mock_get_no_clubs(*args: Any, **kwargs: Any) -> list[dict]: return [] -def mock_get_user_info(*args, **kwargs): +def mock_get_user_info(*args: Any, **kwargs: Any) -> list[dict]: with open("tests/portal/get_user_info.json") as data: return json.load(data) -def mock_get_club_info(*args, **kwargs): +def mock_get_club_info(*args: Any, **kwargs: Any) -> list[dict]: with open("tests/portal/get_club_info.json") as data: return json.load(data) @@ -38,11 +36,13 @@ class TestPosts(TestCase): """Tests Created/Update/Retrieve for Posts""" @mock.patch("portal.serializers.get_user_clubs", mock_get_user_clubs) - def setUp(self): + def setUp(self) -> None: call_command("load_target_populations", "--years", "2022, 2023, 2024, 2025") - self.target_id = TargetPopulation.objects.get(population="2024").id - self.client = APIClient() - self.test_user = User.objects.create_user("user", "user@seas.upenn.edu", "user") + self.target_id: int = TargetPopulation.objects.get(population="2024").id + self.client: APIClient = APIClient() + self.test_user: UserType = DjangoUserModel.objects.create_user( + "user", "user@seas.upenn.edu", "user" + ) self.client.force_authenticate(user=self.test_user) payload = { @@ -57,12 +57,13 @@ def setUp(self): } self.client.post("/portal/posts/", payload) post_1 = Post.objects.all().first() + assert post_1 is not None post_1.status = Post.STATUS_APPROVED post_1.save() - self.id = post_1.id + self.post_id = post_1.id @mock.patch("portal.serializers.get_user_clubs", mock_get_user_clubs) - def test_create_post(self): + def test_create_post(self) -> None: # Creates an unapproved post payload = { "club_code": "pennlabs", @@ -81,7 +82,7 @@ def test_create_post(self): self.assertEqual(None, Post.objects.get(id=res_json["id"]).admin_comment) @mock.patch("portal.serializers.get_user_clubs", mock_get_no_clubs) - def test_fail_post(self): + def test_fail_post(self) -> None: # Creates an unapproved post payload = { "club_code": "pennlabs", @@ -101,29 +102,31 @@ def test_fail_post(self): @mock.patch("portal.views.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs) - def test_update_post(self): + def test_update_post(self) -> None: payload = {"title": "New Test Title 3"} - response = self.client.patch(f"/portal/posts/{self.id}/", payload) + response = self.client.patch(f"/portal/posts/{self.post_id}/", payload) res_json = json.loads(response.content) - self.assertEqual(self.id, res_json["id"]) - self.assertEqual("New Test Title 3", Post.objects.get(id=self.id).title) + self.assertEqual(self.post_id, res_json["id"]) + self.assertEqual("New Test Title 3", Post.objects.get(id=self.post_id).title) # since the user is not an admin, approved should be set to false after update self.assertEqual(Post.STATUS_DRAFT, res_json["status"]) @mock.patch("portal.views.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs) - def test_update_post_admin(self): - admin = User.objects.create_superuser("admin@upenn.edu", "admin", "admin") + def test_update_post_admin(self) -> None: + admin: UserType = DjangoUserModel.objects.create_superuser( + "admin@upenn.edu", "admin", "admin" + ) self.client.force_authenticate(user=admin) payload = {"title": "New Test Title 3"} - response = self.client.patch(f"/portal/posts/{self.id}/", payload) + response = self.client.patch(f"/portal/posts/{self.post_id}/", payload) res_json = json.loads(response.content) - self.assertEqual(self.id, res_json["id"]) + self.assertEqual(self.post_id, res_json["id"]) self.assertEqual(Post.STATUS_APPROVED, res_json["status"]) @mock.patch("portal.serializers.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.logic.get_user_info", mock_get_user_info) - def test_browse(self): + def test_browse(self) -> None: payload = { "club_code": "pennlabs", "title": "Test Title 2", @@ -139,7 +142,7 @@ def test_browse(self): self.assertEqual(1, len(res_json)) self.assertEqual(2, Post.objects.all().count()) - def test_review_post_no_admin_comment(self): + def test_review_post_no_admin_comment(self) -> None: # No admin comment Post.objects.create( club_code="notpennlabs", @@ -147,7 +150,9 @@ def test_review_post_no_admin_comment(self): subtitle="Test subtitle 2", expire_date=timezone.localtime() + datetime.timedelta(days=1), ) - admin = User.objects.create_superuser("admin@upenn.edu", "admin", "admin") + admin: UserType = DjangoUserModel.objects.create_superuser( + "admin@upenn.edu", "admin", "admin" + ) self.client.force_authenticate(user=admin) response = self.client.get("/portal/posts/review/") res_json = json.loads(response.content) @@ -158,7 +163,7 @@ def test_review_post_no_admin_comment(self): @mock.patch("portal.serializers.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs) @mock.patch("utils.email.send_automated_email.delay_on_commit") - def test_send_email_on_create(self, mock_send_email): + def test_send_email_on_create(self, mock_send_email: mock.Mock) -> None: payload = { "club_code": "pennlabs", "title": "Test Title 2", @@ -175,7 +180,7 @@ def test_send_email_on_create(self, mock_send_email): @mock.patch("portal.serializers.get_user_clubs", mock_get_user_clubs) @mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs) @mock.patch("utils.email.send_automated_email.delay_on_commit") - def test_send_email_on_status_change(self, mock_send_email): + def test_send_email_on_status_change(self, mock_send_email: mock.Mock) -> None: payload = { "club_code": "pennlabs", "title": "Test Title 2", @@ -190,8 +195,10 @@ def test_send_email_on_status_change(self, mock_send_email): mock_send_email.assert_called_once() post = Post.objects.last() + assert post is not None + creator = cast(UserType, post.creator) post.status = Post.STATUS_APPROVED post.save() self.assertEqual(mock_send_email.call_count, 2) - self.assertEqual(mock_send_email.call_args[0][1], [post.creator.email]) + self.assertEqual(mock_send_email.call_args[0][1], [creator.email]) diff --git a/backend/tests/sublet/test_permissions.py b/backend/tests/sublet/test_permissions.py index 39a26c5b..3be61b79 100644 --- a/backend/tests/sublet/test_permissions.py +++ b/backend/tests/sublet/test_permissions.py @@ -1,13 +1,10 @@ import json -from django.contrib.auth import get_user_model from django.test import TestCase from rest_framework.test import APIClient from sublet.models import Amenity, Sublet - - -User = get_user_model() +from utils.types import DjangoUserModel, UserType class SubletPermissions(TestCase): @@ -16,28 +13,34 @@ class SubletPermissions(TestCase): class OfferPermissions(TestCase): - def setUp(self): - self.client = APIClient() - self.admin = User.objects.create_superuser("admin", "admin@example.com", "admin") - self.user1 = User.objects.create_user("user1", "user1@seas.upenn.edu", "user1") - self.user2 = User.objects.create_user("user2", "user2@seas.upenn.edu", "user2") + def setUp(self) -> None: + self.client: APIClient = APIClient() + self.admin: UserType = DjangoUserModel.objects.create_superuser( + "admin", "admin@example.com", "admin" + ) + self.user1: UserType = DjangoUserModel.objects.create_user( + "user1", "user1@seas.upenn.edu", "user1" + ) + self.user2: UserType = DjangoUserModel.objects.create_user( + "user2", "user2@seas.upenn.edu", "user2" + ) for i in range(1, 6): Amenity.objects.create(name=f"Amenity{str(i)}") # TODO: Add amenities with open("tests/sublet/mock_sublets.json") as data: - data = json.load(data) - self.sublet1 = Sublet.objects.create(subletter=self.admin, **data[0]) - self.sublet2 = Sublet.objects.create(subletter=self.user1, **data[0]) - self.sublet3 = Sublet.objects.create(subletter=self.user2, **data[1]) + mock_data = json.load(data) + self.sublet1 = Sublet.objects.create(subletter=self.admin, **mock_data[0]) + self.sublet2 = Sublet.objects.create(subletter=self.user1, **mock_data[0]) + self.sublet3 = Sublet.objects.create(subletter=self.user2, **mock_data[1]) - def test_authentication(self): + def test_authentication(self) -> None: prop_url = f"/sublet/properties/{str(self.sublet1.id)}/offers/" self.assertEqual(self.client.get(prop_url).status_code, 403) self.assertEqual(self.client.post(prop_url).status_code, 403) self.assertEqual(self.client.delete(prop_url).status_code, 403) self.assertEqual(self.client.get("/sublet/offers/").status_code, 403) - def create_create_offer(self): + def test_create_offer(self) -> None: prop_url = f"/sublet/properties/{str(self.sublet1.id)}/offers/" payload = { "email": "offer@seas.upenn.edu", @@ -50,7 +53,7 @@ def create_create_offer(self): self.client.force_authenticate(user=u) self.assertEqual(self.client.post(prop_url, payload).status_code, 201) - def test_delete_offer(self): + def test_delete_offer(self) -> None: prop_url = f"/sublet/properties/{str(self.sublet1.id)}/offers/" payload = { "email": "offer@seas.upenn.edu", @@ -64,7 +67,7 @@ def test_delete_offer(self): self.client.post(prop_url, payload) self.assertEqual(self.client.delete(prop_url).status_code, 204) - def test_get_offers_property(self): + def test_get_offers_property(self) -> None: prop_url = f"/sublet/properties/{str(self.sublet2.id)}/offers/" payload = { "email": "offer@seas.upenn.edu", @@ -81,40 +84,46 @@ def test_get_offers_property(self): self.client.force_authenticate(user=u) self.assertEqual(self.client.get(prop_url).status_code, c) - def test_get_offers_user(self): + def test_get_offers_user(self) -> None: self.client.force_authenticate(user=self.user1) self.assertEqual(self.client.get("/sublet/offers/").status_code, 200) class FavoritePermissions(TestCase): - def setUp(self): - self.client = APIClient() - self.admin = User.objects.create_superuser("admin", "admin@example.com", "admin") - self.user1 = User.objects.create_user("user1", "user1@seas.upenn.edu", "user1") - self.user2 = User.objects.create_user("user2", "user2@seas.upenn.edu", "user2") + def setUp(self) -> None: + self.client: APIClient = APIClient() + self.admin: UserType = DjangoUserModel.objects.create_superuser( + "admin", "admin@example.com", "admin" + ) + self.user1: UserType = DjangoUserModel.objects.create_user( + "user1", "user1@seas.upenn.edu", "user1" + ) + self.user2: UserType = DjangoUserModel.objects.create_user( + "user2", "user2@seas.upenn.edu", "user2" + ) for i in range(1, 6): Amenity.objects.create(name=f"Amenity{str(i)}") # TODO: Add amenities with open("tests/sublet/mock_sublets.json") as data: - data = json.load(data) - self.sublet1 = Sublet.objects.create(subletter=self.admin, **data[0]) - self.sublet2 = Sublet.objects.create(subletter=self.user1, **data[0]) - self.sublet3 = Sublet.objects.create(subletter=self.user2, **data[1]) + mock_data = json.load(data) + self.sublet1 = Sublet.objects.create(subletter=self.admin, **mock_data[0]) + self.sublet2 = Sublet.objects.create(subletter=self.user1, **mock_data[0]) + self.sublet3 = Sublet.objects.create(subletter=self.user2, **mock_data[1]) - def test_authentication(self): + def test_authentication(self) -> None: prop_url = f"/sublet/properties/{str(self.sublet1.id)}/favorites/" self.assertEqual(self.client.post(prop_url).status_code, 403) self.assertEqual(self.client.delete(prop_url).status_code, 403) self.assertEqual(self.client.get("/sublet/favorites/").status_code, 403) - def test_create_favorite(self): + def test_create_favorite(self) -> None: prop_url = f"/sublet/properties/{str(self.sublet1.id)}/favorites/" users = [self.admin, self.user1] for u in users: self.client.force_authenticate(user=u) self.assertEqual(self.client.post(prop_url).status_code, 201) - def test_delete_favorite(self): + def test_delete_favorite(self) -> None: prop_url = f"/sublet/properties/{str(self.sublet1.id)}/favorites/" users = [self.admin, self.user1] for u in users: @@ -122,6 +131,6 @@ def test_delete_favorite(self): self.client.post(prop_url) self.assertEqual(self.client.delete(prop_url).status_code, 204) - def test_get_favorites_user(self): + def test_get_favorites_user(self) -> None: self.client.force_authenticate(user=self.user1) self.assertEqual(self.client.get("/sublet/favorites/").status_code, 200) diff --git a/backend/tests/sublet/test_sublets.py b/backend/tests/sublet/test_sublets.py index 793fc5f9..a10757e5 100644 --- a/backend/tests/sublet/test_sublets.py +++ b/backend/tests/sublet/test_sublets.py @@ -1,40 +1,45 @@ import json +from typing import cast from unittest.mock import MagicMock -from django.contrib.auth import get_user_model from django.core.files.storage import Storage +from django.forms import ImageField from django.test import TestCase from rest_framework.test import APIClient from sublet.models import Amenity, Offer, Sublet, SubletImage - - -User = get_user_model() +from utils.types import DjangoUserModel, UserType class TestSublets(TestCase): """Tests Create/Update/Retrieve/List for sublets""" - def setUp(self): - self.user = User.objects.create_user("user", "user@seas.upenn.edu", "user") - self.client = APIClient() + def setUp(self) -> None: + self.user: UserType = DjangoUserModel.objects.create_user( + "user", "user@seas.upenn.edu", "user" + ) + self.client: APIClient = APIClient() self.client.force_authenticate(user=self.user) - test_user = User.objects.create_user("user1", "user1@seas.upenn.edu", "user1") + test_user: UserType = DjangoUserModel.objects.create_user( + "user1", "user1@seas.upenn.edu", "user1" + ) for i in range(1, 6): Amenity.objects.create(name=f"Amenity{str(i)}") with open("tests/sublet/mock_sublets.json") as data: - data = json.load(data) - self.test_sublet1 = Sublet.objects.create(subletter=self.user, **data[0]) - self.test_sublet2 = Sublet.objects.create(subletter=test_user, **data[1]) + mock_data = json.load(data) + self.test_sublet1 = Sublet.objects.create(subletter=self.user, **mock_data[0]) + self.test_sublet2 = Sublet.objects.create(subletter=test_user, **mock_data[1]) storage_mock = MagicMock(spec=Storage, name="StorageMock") storage_mock.generate_filename = lambda filename: filename storage_mock.save = MagicMock(side_effect=lambda name, *args, **kwargs: name) storage_mock.url = MagicMock(name="url") storage_mock.url.return_value = "http://penn-mobile.com/mock-image.png" - SubletImage._meta.get_field("image").storage = storage_mock - def test_create_sublet(self): + image_field = cast(ImageField, SubletImage._meta.get_field("image")) + image_field.storage = storage_mock # type: ignore + + def test_create_sublet(self) -> None: # Create a new sublet using the serializer payload = { "title": "Test Sublet1", @@ -66,13 +71,14 @@ def test_create_sublet(self): "end_date", "amenities", ] - [self.assertEqual(payload[key], res_json[key]) for key in match_keys] + for key in match_keys: + self.assertEqual(payload[key], res_json[key]) self.assertIn("id", res_json) self.assertEqual(self.user.id, res_json["subletter"]) self.assertEqual(2, len(res_json["amenities"])) self.assertIn("images", res_json) - def test_create_sublet_with_profanity(self): + def test_create_sublet_with_profanity(self) -> None: # Payload with profanity in the title and description payload_with_profanity = { "title": "fuck", @@ -101,7 +107,7 @@ def test_create_sublet_with_profanity(self): res_json["description"][0], "The description contains inappropriate language." ) - def test_update_sublet(self): + def test_update_sublet(self) -> None: # Create a sublet to be updated payload = { "title": "Test Sublet2", @@ -124,12 +130,14 @@ def test_update_sublet(self): response = self.client.patch(f"/sublet/properties/{str(old_id)}/", data) res_json = json.loads(response.content) self.assertEqual(3, res_json["beds"]) - self.assertEqual(old_id, Sublet.objects.all().last().id) - self.assertEqual("New Title", Sublet.objects.get(id=old_id).title) + last_sublet = Sublet.objects.all().last() + assert last_sublet is not None + self.assertEqual(old_id, last_sublet.id) + self.assertEqual("New Title", last_sublet.title) self.assertEqual("New Title", res_json["title"]) self.assertEqual(1, len(res_json["amenities"])) - def test_browse_sublets(self): + def test_browse_sublets(self) -> None: response = self.client.get("/sublet/properties/") res_json = json.loads(response.content) first_length = len(res_json) @@ -158,7 +166,7 @@ def test_browse_sublets(self): self.assertEqual(sublet.beds, 2) self.assertEqual(sublet.baths, 1) - def test_browse_filtered(self): + def test_browse_filtered(self) -> None: payload = { "title": "Test Sublet2", "address": "1234 Test Street", @@ -175,12 +183,12 @@ def test_browse_filtered(self): } response = self.client.post("/sublet/properties/", payload) old_id = json.loads(response.content)["id"] - payload = { + filter_payload = { "title": "Sublet2", - "max_price": 999, - "min_price": 499, + "max_price": "999", + "min_price": "499", } - response = self.client.get("/sublet/properties/", payload) + response = self.client.get("/sublet/properties/", filter_payload) res_json = json.loads(response.content) sublet = res_json[0] self.assertEqual(1, len(res_json)) @@ -208,7 +216,7 @@ def test_browse_filtered(self): res_json = json.loads(response.content) self.assertEqual(old_length, len(res_json)) - def test_browse_sublet(self): + def test_browse_sublet(self) -> None: # browse single sublet by id payload = { "title": "Test Sublet2", @@ -234,19 +242,19 @@ def test_browse_sublet(self): self.assertEqual(res_json["baths"], "1.5") self.assertEqual(res_json["amenities"], ["Amenity1", "Amenity2"]) - def test_delete_sublet(self): + def test_delete_sublet(self) -> None: sublets_count = Sublet.objects.all().count() self.client.delete(f"/sublet/properties/{str(self.test_sublet1.id)}/") self.assertEqual(sublets_count - 1, Sublet.objects.all().count()) self.assertFalse(Sublet.objects.filter(id=1).exists()) - def test_amenities(self): + def test_amenities(self) -> None: response = self.client.get("/sublet/amenities/") res_json = json.loads(response.content) for i in range(1, 6): self.assertIn(f"Amenity{i}", res_json) - def test_create_image(self): + def test_create_image(self) -> None: with open("tests/sublet/mock_image.jpg", "rb") as image: response = self.client.post( f"/sublet/properties/{str(self.test_sublet1.id)}/images/", {"images": image} @@ -254,9 +262,11 @@ def test_create_image(self): self.assertEqual(response.status_code, 201) images = Sublet.objects.get(id=self.test_sublet1.id).images.all() self.assertTrue(images.exists()) - self.assertEqual(self.test_sublet1.id, images.first().sublet.id) + first_image = images.first() + assert first_image is not None + self.assertEqual(self.test_sublet1.id, first_image.sublet.id) - def test_create_delete_images(self): + def test_create_delete_images(self) -> None: with open("tests/sublet/mock_image.jpg", "rb") as image: with open("tests/sublet/mock_image.jpg", "rb") as image2: response = self.client.post( @@ -266,10 +276,12 @@ def test_create_delete_images(self): ) self.assertEqual(response.status_code, 201) images = Sublet.objects.get(id=self.test_sublet1.id).images.all() - image_id1 = images.first().id + first_image = images.first() + assert first_image is not None + image_id1 = first_image.id self.assertTrue(images.exists()) self.assertEqual(2, images.count()) - self.assertEqual(self.test_sublet1.id, images.first().sublet.id) + self.assertEqual(self.test_sublet1.id, first_image.sublet.id) response = self.client.delete(f"/sublet/properties/images/{image_id1}/") self.assertEqual(response.status_code, 204) self.assertFalse(SubletImage.objects.filter(id=image_id1).exists()) @@ -279,20 +291,22 @@ def test_create_delete_images(self): class TestOffers(TestCase): """Tests Create/Delete/List for offers""" - def setUp(self): - self.user = User.objects.create_user("user", "user@seas.upenn.edu", "user") - self.client = APIClient() + def setUp(self) -> None: + self.user: UserType = DjangoUserModel.objects.create_user( + "user", "user@seas.upenn.edu", "user" + ) + self.client: APIClient = APIClient() self.client.force_authenticate(user=self.user) - self.test_user = User.objects.create_user("user1", "user") + self.test_user: UserType = DjangoUserModel.objects.create_user("user1", "user") for i in range(1, 6): Amenity.objects.create(name=f"Amenity{str(i)}") # TODO: Not sure how to add these amenities to the sublets, but not important for now with open("tests/sublet/mock_sublets.json") as data: - data = json.load(data) - self.first_sublet = Sublet.objects.create(subletter=self.user, **data[0]) - self.second_sublet = Sublet.objects.create(subletter=self.test_user, **data[1]) + mock_data = json.load(data) + self.first_sublet = Sublet.objects.create(subletter=self.user, **mock_data[0]) + self.second_sublet = Sublet.objects.create(subletter=self.test_user, **mock_data[1]) - def test_create_offer(self): + def test_create_offer(self) -> None: prop_url = f"/sublet/properties/{str(self.second_sublet.id)}/offers/" payload = { "email": "offer@seas.upenn.edu", @@ -317,7 +331,7 @@ def test_create_offer(self): self.assertIsNotNone(offer.id) self.assertIsNotNone(offer.created_date) - def test_delete_offer(self): + def test_delete_offer(self) -> None: prop_url1 = f"/sublet/properties/{str(self.first_sublet.id)}/offers/" prop_url2 = f"/sublet/properties/{str(self.second_sublet.id)}/offers/" payload = { @@ -334,7 +348,7 @@ def test_delete_offer(self): self.client.delete(prop_url2) self.assertFalse(Offer.objects.filter(user=self.user, sublet=self.second_sublet).exists()) - def test_get_offers_property(self): + def test_get_offers_property(self) -> None: response = self.client.get("/sublet/offers/") res_json = json.loads(response.content) self.assertEqual(0, len(res_json)) @@ -376,7 +390,7 @@ def test_get_offers_property(self): self.assertIsNotNone(offer["id"]) self.assertIsNotNone(offer["created_date"]) - def test_get_offer_user(self): + def test_get_offer_user(self) -> None: response = self.client.get("/sublet/offers/") res_json = json.loads(response.content) self.assertEqual(0, len(res_json)) @@ -420,20 +434,22 @@ def test_get_offer_user(self): class TestFavorites(TestCase): """Tests Create/Delete/List for favorites""" - def setUp(self): - self.user = User.objects.create_user("user", "user@seas.upenn.edu", "user") - self.client = APIClient() + def setUp(self) -> None: + self.user: UserType = DjangoUserModel.objects.create_user( + "user", "user@seas.upenn.edu", "user" + ) + self.client: APIClient = APIClient() self.client.force_authenticate(user=self.user) - test_user = User.objects.create_user("user1", "user") + test_user: UserType = DjangoUserModel.objects.create_user("user1", "user") for i in range(1, 6): Amenity.objects.create(name=f"Amenity{str(i)}") # TODO: Not sure how to add these amenities to the sublets, but not important for now with open("tests/sublet/mock_sublets.json") as data: - data = json.load(data) - self.first_sublet = Sublet.objects.create(subletter=self.user, **data[0]) - self.second_sublet = Sublet.objects.create(subletter=test_user, **data[1]) + mock_data = json.load(data) + self.first_sublet = Sublet.objects.create(subletter=self.user, **mock_data[0]) + self.second_sublet = Sublet.objects.create(subletter=test_user, **mock_data[1]) - def test_create_favorite(self): + def test_create_favorite(self) -> None: prop_url1 = f"/sublet/properties/{str(self.first_sublet.id)}/favorites/" prop_url2 = f"/sublet/properties/{str(self.second_sublet.id)}/favorites/" self.client.post(prop_url2) @@ -444,7 +460,7 @@ def test_create_favorite(self): self.assertTrue(self.user.sublets_favorited.filter(pk=self.first_sublet.id).exists()) self.assertEqual(self.client.post(prop_url1).status_code, 406) - def test_delete_favorite(self): + def test_delete_favorite(self) -> None: self.client.post(f"/sublet/properties/{str(self.second_sublet.id)}/favorites/") self.client.post(f"/sublet/properties/{str(self.first_sublet.id)}/favorites/") self.client.delete(f"/sublet/properties/{str(self.first_sublet.id)}/favorites/") @@ -460,7 +476,7 @@ def test_delete_favorite(self): self.assertFalse(self.user.sublets_favorited.filter(pk=self.second_sublet.id).exists()) self.assertFalse(self.user.sublets_favorited.filter(pk=self.first_sublet.id).exists()) - def test_get_favorite_user(self): + def test_get_favorite_user(self) -> None: response = self.client.get("/sublet/favorites/") res_json = json.loads(response.content) self.assertEqual(len(res_json), 0) diff --git a/backend/tests/user/test_notifs.py b/backend/tests/user/test_notifs.py index 512b271a..e860ba8d 100644 --- a/backend/tests/user/test_notifs.py +++ b/backend/tests/user/test_notifs.py @@ -1,27 +1,25 @@ import datetime import json +from typing import Any from unittest import mock -from django.contrib.auth import get_user_model from django.core.management import call_command from django.test import TestCase from django.utils import timezone from identity.identity import attest, container, get_platform_jwks from rest_framework.test import APIClient -from gsr_booking.models import GSR, Group, GSRBooking, Reservation +from gsr_booking.models import GSR, GSRBooking, Reservation from user.models import NotificationSetting, NotificationToken +from utils.types import DjangoUserModel, UserType -User = get_user_model() - - -def initialize_b2b(): +def initialize_b2b() -> None: get_platform_jwks() attest() -def get_b2b_client(): +def get_b2b_client() -> APIClient: client = APIClient() client.logout() client.credentials(HTTP_AUTHORIZATION="Bearer " + container.access_jwt.serialize()) @@ -29,33 +27,37 @@ def get_b2b_client(): class MockAPNsClient: - def send_notification(self, token, payload, topic): + def send_notification(self, token: str, payload: dict, topic: str) -> None: del token, payload, topic pass - def send_notification_batch(self, notifications, topic): + def send_notification_batch(self, notifications: list[dict], topic: str) -> None: del notifications, topic pass -def mock_client(is_dev): +def mock_client(is_dev: bool) -> MockAPNsClient: return MockAPNsClient() class TestNotificationToken(TestCase): """Tests for CRUD Notification Tokens""" - def setUp(self): - self.client = APIClient() - self.test_user = User.objects.create_user("user", "user@seas.upenn.edu", "user") + def setUp(self) -> None: + self.client: APIClient = APIClient() + self.test_user: UserType = DjangoUserModel.objects.create_user( + "user", "user@seas.upenn.edu", "user" + ) self.client.force_authenticate(user=self.test_user) - def test_post_save(self): + def test_post_save(self) -> None: # asserts that post save hook in creating tokens works correctly self.assertEqual(1, NotificationToken.objects.all().count()) - self.assertEqual(self.test_user, NotificationToken.objects.all().first().user) + notification_token = NotificationToken.objects.all().first() + assert notification_token is not None + self.assertEqual(self.test_user, notification_token.user) - def test_create_update_token(self): + def test_create_update_token(self) -> None: NotificationToken.objects.all().delete() # test that creating token returns correct response @@ -74,13 +76,13 @@ def test_create_update_token(self): self.assertEqual("newtoken", res_json["token"]) self.assertEqual(1, NotificationToken.objects.all().count()) - def test_create_token_again_fail(self): + def test_create_token_again_fail(self) -> None: # test that creating token returns correct response payload = {"kind": "IOS", "token": "test123"} response = self.client.post("/user/notifications/tokens/", payload) self.assertEqual(response.status_code, 400) - def test_get_token(self): + def test_get_token(self) -> None: NotificationToken.objects.all().delete() # create token @@ -99,13 +101,15 @@ def test_get_token(self): class TestNotificationSetting(TestCase): """Tests for CRUD Notification Settings""" - def setUp(self): - self.client = APIClient() - self.test_user = User.objects.create_user("user", "user@seas.upenn.edu", "user") + def setUp(self) -> None: + self.client: APIClient = APIClient() + self.test_user: UserType = DjangoUserModel.objects.create_user( + "user", "user@seas.upenn.edu", "user" + ) self.client.force_authenticate(user=self.test_user) initialize_b2b() - def test_get_settings(self): + def test_get_settings(self) -> None: # test that settings visible via GET response = self.client.get("/user/notifications/settings/") res_json = json.loads(response.content) @@ -113,9 +117,9 @@ def test_get_settings(self): for setting in res_json: self.assertFalse(setting["enabled"]) - def test_invalid_settings_update(self): + def test_invalid_settings_update(self) -> None: NotificationToken.objects.all().delete() - payload = {"kind": "IOS", "token": "test123"} + payload: dict[str, Any] = {"kind": "IOS", "token": "test123"} response = self.client.post("/user/notifications/tokens/", payload) res_json = json.loads(response.content) @@ -128,13 +132,13 @@ def test_invalid_settings_update(self): self.assertEqual(res_json["service"], "PENN_MOBILE") self.assertTrue(res_json["enabled"]) - def test_valid_settings_update(self): + def test_valid_settings_update(self) -> None: NotificationToken.objects.all().delete() response = self.client.get("/user/notifications/settings/PENN_MOBILE/check/") res_json = json.loads(response.content) self.assertFalse(res_json["enabled"]) - payload = {"kind": "IOS", "token": "test123"} + payload: dict[str, Any] = {"kind": "IOS", "token": "test123"} response = self.client.post("/user/notifications/tokens/", payload) res_json = json.loads(response.content) @@ -145,7 +149,7 @@ def test_valid_settings_update(self): response = self.client.patch(f"/user/notifications/settings/{settings_id}/", payload) self.assertEqual(response.status_code, 400) - def test_create_update_check_settings(self): + def test_create_update_check_settings(self) -> None: # test that invalid settings are rejected NotificationSetting.objects.filter(service="PENN_MOBILE").delete() payload = {"service": "Penn Mobile", "enabled": True} @@ -181,7 +185,7 @@ def test_create_update_check_settings(self): res_json = json.loads(response.content) self.assertTrue(res_json["enabled"]) - def test_check_fail(self): + def test_check_fail(self) -> None: # since invalid setting, should return error response = self.client.get("/user/notifications/settings/PENN_MOBIL/check/") self.assertEqual(response.status_code, 400) @@ -205,7 +209,7 @@ def test_check_fail(self): # self.assertEqual(res_json["service"], "PENN_MOBILE") # self.assertFalse(res_json["enabled"]) - def test_b2b_auth_fails(self): + def test_b2b_auth_fails(self) -> None: self.client.logout() response = self.client.get("/user/notifications/settings/PENN_MOBILE/check/?pennkey=user") self.assertEqual(response.status_code, 403) @@ -214,18 +218,22 @@ def test_b2b_auth_fails(self): class TestNotificationAlert(TestCase): """Tests for sending Notification Alerts""" - def setUp(self): - self.client = APIClient() + def setUp(self) -> None: + self.client: APIClient = APIClient() # create user1 - self.test_user = User.objects.create_user("user", "user@seas.upenn.edu", "user") + self.test_user: UserType = DjangoUserModel.objects.create_user( + "user", "user@seas.upenn.edu", "user" + ) self.client.force_authenticate(user=self.test_user) token_obj = NotificationToken.objects.get(user=self.test_user) token_obj.token = "test123" token_obj.save() # create user2 - self.test_user = User.objects.create_user("user2", "user2@seas.upenn.edu", "user2") + self.test_user = DjangoUserModel.objects.create_user( + "user2", "user2@seas.upenn.edu", "user2" + ) self.client.force_authenticate(user=self.test_user) token_obj = NotificationToken.objects.get(user=self.test_user) token_obj.token = "test234" @@ -235,7 +243,7 @@ def setUp(self): setting.save() # create user3 - user3 = User.objects.create_user("user3", "user3@seas.upenn.edu", "user3") + user3 = DjangoUserModel.objects.create_user("user3", "user3@seas.upenn.edu", "user3") token_obj = NotificationToken.objects.get(user=user3) token_obj.token = "test234" token_obj.save() @@ -246,7 +254,7 @@ def setUp(self): initialize_b2b() @mock.patch("user.notifications.get_client", mock_client) - def test_failed_notif(self): + def test_failed_notif(self) -> None: # missing title payload = {"body": ":D", "service": "PENN_MOBILE"} response = self.client.post("/user/notifications/alerts/", payload) @@ -262,7 +270,7 @@ def test_failed_notif(self): self.assertEqual(response.status_code, 400) @mock.patch("user.notifications.get_client", mock_client) - def test_single_notif(self): + def test_single_notif(self) -> None: # test notif fail when setting is false payload = {"title": "Test", "body": ":D", "service": "OHQ"} response = self.client.post("/user/notifications/alerts/", payload) @@ -278,7 +286,7 @@ def test_single_notif(self): self.assertEqual(0, len(res_json["failed_users"])) @mock.patch("user.notifications.get_client", mock_client) - def test_batch_notif(self): + def test_batch_notif(self) -> None: # update all settings to be enabled NotificationSetting.objects.all().update(enabled=True) @@ -319,10 +327,12 @@ def test_batch_notif(self): class TestSendGSRReminders(TestCase): """Test Sending GSR Reminders""" - def setUp(self): + def setUp(self) -> None: call_command("load_gsrs") - self.client = APIClient() - self.test_user = User.objects.create_user("user", "user@seas.upenn.edu", "user") + self.client: APIClient = APIClient() + self.test_user: UserType = DjangoUserModel.objects.create_user( + "user", "user@seas.upenn.edu", "user" + ) self.client.force_authenticate(user=self.test_user) # enabling tokens and settings @@ -350,38 +360,41 @@ def setUp(self): start=g.start, end=g.end, creator=self.test_user, - group=Group.objects.get(owner=self.test_user), ) g.reservation = r g.save() @mock.patch("user.notifications.get_client", mock_client) - def test_send_reminder(self): + def test_send_reminder(self) -> None: call_command("send_gsr_reminders") r = Reservation.objects.all().first() + assert r is not None self.assertTrue(r.reminder_sent) - def test_send_reminder_no_gsrs(self): + def test_send_reminder_no_gsrs(self) -> None: GSRBooking.objects.all().delete() call_command("send_gsr_reminders") r = Reservation.objects.all().first() + assert r is not None self.assertFalse(r.reminder_sent) class TestSendShadowNotifs(TestCase): """Test Sending Shadow Notifications""" - def setUp(self): - self.client = APIClient() - self.test_user = User.objects.create_user("user", "user@seas.upenn.edu", "user") + def setUp(self) -> None: + self.client: APIClient = APIClient() + self.test_user: UserType = DjangoUserModel.objects.create_user( + "user", "user@seas.upenn.edu", "user" + ) self.client.force_authenticate(user=self.test_user) token_obj = NotificationToken.objects.get(user=self.test_user) token_obj.token = "test123" token_obj.save() @mock.patch("user.notifications.get_client", mock_client) - def test_shadow_notifications(self): + def test_shadow_notifications(self) -> None: # call command on every user call_command("send_shadow_notifs", "yes", '{"test":"test"}') diff --git a/backend/tests/user/test_user.py b/backend/tests/user/test_user.py index 27bc8b83..010f9cf2 100644 --- a/backend/tests/user/test_user.py +++ b/backend/tests/user/test_user.py @@ -1,16 +1,12 @@ from django.contrib import auth -from django.contrib.auth import get_user_model from django.test import TestCase from rest_framework.test import APIClient from user.models import Profile -User = get_user_model() - - class UserTestCase(TestCase): - def setUp(self): + def setUp(self) -> None: self.user1 = { "pennid": 1, "first_name": "First", @@ -35,21 +31,28 @@ def setUp(self): "token": {"access_token": "abc", "refresh_token": "123", "expires_in": 100}, } - self.client = APIClient() + self.client: APIClient = APIClient() self.client.login(username="user1", password="password") - def test_user1_profile(self): + def test_user1_profile(self) -> None: user = auth.authenticate(remote_user=self.user1) self.assertEqual(1, Profile.objects.all().count()) - self.assertEqual(user, Profile.objects.all().first().user) - self.assertEqual("user", str(Profile.objects.all().first())) + profile = Profile.objects.all().first() + assert profile is not None + self.assertEqual(user, profile.user) + self.assertEqual("user", str(profile)) - def test_user2_profile(self): + def test_user2_profile(self) -> None: self.assertEqual(0, Profile.objects.all().count()) user = auth.authenticate(remote_user=self.user2) + assert user is not None self.assertEqual(1, Profile.objects.all().count()) - self.assertEqual(user, Profile.objects.all().first().user) - user.name = "New Name" + profile = Profile.objects.all().first() + assert profile is not None + self.assertEqual(user, profile.user) + user.name = "New Name" # type: ignore user.save() self.assertEqual(1, Profile.objects.all().count()) - self.assertEqual(user, Profile.objects.all().first().user) + profile = Profile.objects.all().first() + assert profile is not None + self.assertEqual(user, profile.user) diff --git a/backend/tests/utils/test_email.py b/backend/tests/utils/test_email.py index 1b033c6c..b1a4a5e8 100644 --- a/backend/tests/utils/test_email.py +++ b/backend/tests/utils/test_email.py @@ -1,31 +1,30 @@ from unittest import mock -from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.test import TestCase from utils.email import get_backend_manager_emails, send_automated_email, send_mail - - -User = get_user_model() +from utils.types import DjangoUserModel, UserType class EmailTestCase(TestCase): - def setUp(self): - self.group = Group.objects.create(name="backend_managers") - self.user1 = User.objects.create_user( + def setUp(self) -> None: + self.group: Group = Group.objects.create(name="backend_managers") + self.user1: UserType = DjangoUserModel.objects.create_user( username="user1", password="password", email="user1@domain.com" ) - self.user2 = User.objects.create_user( + self.user2: UserType = DjangoUserModel.objects.create_user( username="user2", password="password", email="user2@domain.com" ) - self.user3 = User.objects.create_user(username="user3", password="password") + self.user3: UserType = DjangoUserModel.objects.create_user( + username="user3", password="password" + ) - self.group.user_set.add(self.user1) - self.group.user_set.add(self.user3) + self.group.user_set.add(self.user1) # type: ignore[attr-defined] + self.group.user_set.add(self.user3) # type: ignore[attr-defined] @mock.patch("utils.email.django_send_mail") - def test_send_mail(self, mock_send_mail): + def test_send_mail(self, mock_send_mail: mock.Mock) -> None: send_mail("testing321", ["test@example.com"], message="test message?!") mock_send_mail.assert_called_once_with( subject="testing321", @@ -36,12 +35,12 @@ def test_send_mail(self, mock_send_mail): html_message=None, ) - def test_send_mail_error(self): + def test_send_mail_error(self) -> None: with self.assertRaises(ValueError): send_mail("testing321", None, message="test message?!") @mock.patch("utils.email.django_send_mail") - def test_send_automated_email(self, mock_send_mail): + def test_send_automated_email(self, mock_send_mail: mock.Mock) -> None: send_automated_email("testing123", ["test@example.com"], "test message?!") html_message = mock_send_mail.call_args[1]["html_message"] mock_send_mail.assert_called_once_with( @@ -55,7 +54,7 @@ def test_send_automated_email(self, mock_send_mail): self.assertIsNotNone(html_message) self.assertIn("test message?!", html_message) - def test_get_backend_manager_emails(self): + def test_get_backend_manager_emails(self) -> None: emails = get_backend_manager_emails() self.assertEqual(emails, ["user1@domain.com"]) diff --git a/backend/tests/utils/test_r_request.py b/backend/tests/utils/test_r_request.py index 409afa90..02adc4e5 100644 --- a/backend/tests/utils/test_r_request.py +++ b/backend/tests/utils/test_r_request.py @@ -1,68 +1,69 @@ from json.decoder import JSONDecodeError +from unittest import mock from unittest.mock import patch from django.test import TestCase -from utils.r_request import RRequest +from utils.r_request import Method, RRequest -def raise_decode_error(): +def raise_decode_error() -> None: raise JSONDecodeError("Invalid JSON data", "invalid_json", 0) class RRequestTestCase(TestCase): - def setUp(self): + def setUp(self) -> None: self.url = "https://pennlabs.org" self.json = {"data": "data"} self.rrequest = RRequest() @patch("requests.request") - def test_successful_request(self, mock_response): + def test_successful_request(self, mock_response: mock.Mock) -> None: mock_response.return_value.status_code = 200 - response = self.rrequest.request("get", self.url) + response = self.rrequest.request(Method.GET, self.url) self.assertEqual(200, response.status_code) @patch("requests.request") - def test_unsuccessful_request(self, mock_response): + def test_unsuccessful_request(self, mock_response: mock.Mock) -> None: mock_response.return_value.status_code = 400 mock_response.return_value.content = "Bad Error" - response = self.rrequest.request("post", self.url, json=self.json) + response = self.rrequest.request(Method.POST, self.url, json=self.json) self.assertEqual(400, response.status_code) self.assertEqual("Bad Error", response.content) @patch("requests.request") - def test_bad_json(self, mock_response): + def test_bad_json(self, mock_response: mock.Mock) -> None: mock_response.return_value.status_code = 200 mock_response.return_value.json = raise_decode_error response = self.rrequest.delete(self.url, json=self.json) self.assertEqual(200, response.status_code) @patch("requests.request") - def test_get_request(self, mock_response): + def test_get_request(self, mock_response: mock.Mock) -> None: mock_response.return_value.status_code = 200 response = self.rrequest.get(self.url) self.assertEqual(200, response.status_code) @patch("requests.request") - def test_post_request(self, mock_response): + def test_post_request(self, mock_response: mock.Mock) -> None: mock_response.return_value.status_code = 200 response = self.rrequest.post(self.url, json=self.json) self.assertEqual(200, response.status_code) @patch("requests.request") - def test_patch_request(self, mock_response): + def test_patch_request(self, mock_response: mock.Mock) -> None: mock_response.return_value.status_code = 200 response = self.rrequest.patch(self.url) self.assertEqual(200, response.status_code) @patch("requests.request") - def test_put_request(self, mock_response): + def test_put_request(self, mock_response: mock.Mock) -> None: mock_response.return_value.status_code = 200 response = self.rrequest.put(self.url, json=self.json) self.assertEqual(200, response.status_code) @patch("requests.request") - def test_delete_request(self, mock_response): + def test_delete_request(self, mock_response: mock.Mock) -> None: mock_response.return_value.status_code = 200 response = self.rrequest.delete(self.url, json=self.json) self.assertEqual(200, response.status_code) diff --git a/backend/user/management/commands/clear_cache.py b/backend/user/management/commands/clear_cache.py index b87d15c7..d5e64f55 100644 --- a/backend/user/management/commands/clear_cache.py +++ b/backend/user/management/commands/clear_cache.py @@ -1,4 +1,5 @@ import logging +from typing import Any import redis from django.conf import settings @@ -30,7 +31,7 @@ def clear_cache() -> int: class Command(BaseCommand): - def handle(self, *args, **options) -> None: + def handle(self, *args: Any, **options: Any) -> None: root_logger = logging.getLogger("") root_logger.setLevel(logging.DEBUG) diff --git a/backend/user/management/commands/profile_info.py b/backend/user/management/commands/profile_info.py index 667f4da2..d2350c14 100644 --- a/backend/user/management/commands/profile_info.py +++ b/backend/user/management/commands/profile_info.py @@ -1,4 +1,5 @@ from argparse import ArgumentParser +from typing import Any from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand @@ -16,7 +17,7 @@ def add_arguments(self, parser: ArgumentParser) -> None: parser.add_argument("--pennkey", type=str, help="pennkey") parser.add_argument("--email", type=str, help="email") - def handle(self, *args, **kwargs): + def handle(self, *args: Any, **kwargs: Any) -> None: if kwargs["pennkey"] is None and kwargs["email"] is None: self.stdout.write("Please provide a pennkey or an email.") return diff --git a/backend/user/migrations/0002_profile_laundry_preferences.py b/backend/user/migrations/0002_profile_laundry_preferences.py index f34836f2..eb606b42 100644 --- a/backend/user/migrations/0002_profile_laundry_preferences.py +++ b/backend/user/migrations/0002_profile_laundry_preferences.py @@ -5,15 +5,12 @@ class Migration(migrations.Migration): - dependencies = [ - ("laundry", "0001_initial"), - ("user", "0001_initial"), - ] + dependencies = [("laundry", "0001_initial"), ("user", "0001_initial")] operations = [ migrations.AddField( model_name="profile", name="laundry_preferences", field=models.ManyToManyField(blank=True, to="laundry.LaundryRoom"), - ), + ) ] diff --git a/backend/user/migrations/0003_profile_dining_preferences.py b/backend/user/migrations/0003_profile_dining_preferences.py index 9b6e3503..592460dc 100644 --- a/backend/user/migrations/0003_profile_dining_preferences.py +++ b/backend/user/migrations/0003_profile_dining_preferences.py @@ -5,15 +5,12 @@ class Migration(migrations.Migration): - dependencies = [ - ("dining", "0001_initial"), - ("user", "0002_profile_laundry_preferences"), - ] + dependencies = [("dining", "0001_initial"), ("user", "0002_profile_laundry_preferences")] operations = [ migrations.AddField( model_name="profile", name="dining_preferences", field=models.ManyToManyField(to="dining.Venue"), - ), + ) ] diff --git a/backend/user/migrations/0004_auto_20210324_1851.py b/backend/user/migrations/0004_auto_20210324_1851.py index 37f3f2d4..448a3b4c 100644 --- a/backend/user/migrations/0004_auto_20210324_1851.py +++ b/backend/user/migrations/0004_auto_20210324_1851.py @@ -5,15 +5,12 @@ class Migration(migrations.Migration): - dependencies = [ - ("dining", "0001_initial"), - ("user", "0003_profile_dining_preferences"), - ] + dependencies = [("dining", "0001_initial"), ("user", "0003_profile_dining_preferences")] operations = [ migrations.AlterField( model_name="profile", name="dining_preferences", field=models.ManyToManyField(blank=True, to="dining.Venue"), - ), + ) ] diff --git a/backend/user/migrations/0005_auto_20211003_2240.py b/backend/user/migrations/0005_auto_20211003_2240.py index f9e011a7..c4abcd54 100644 --- a/backend/user/migrations/0005_auto_20211003_2240.py +++ b/backend/user/migrations/0005_auto_20211003_2240.py @@ -5,20 +5,10 @@ class Migration(migrations.Migration): - dependencies = [ - ("user", "0004_auto_20210324_1851"), - ] + dependencies = [("user", "0004_auto_20210324_1851")] operations = [ - migrations.RemoveField( - model_name="profile", - name="degrees", - ), - migrations.RemoveField( - model_name="profile", - name="expected_graduation", - ), - migrations.DeleteModel( - name="Degree", - ), + migrations.RemoveField(model_name="profile", name="degrees"), + migrations.RemoveField(model_name="profile", name="expected_graduation"), + migrations.DeleteModel(name="Degree"), ] diff --git a/backend/user/migrations/0007_alter_notificationsetting_service.py b/backend/user/migrations/0007_alter_notificationsetting_service.py index 58c68465..ff16cabe 100644 --- a/backend/user/migrations/0007_alter_notificationsetting_service.py +++ b/backend/user/migrations/0007_alter_notificationsetting_service.py @@ -37,9 +37,7 @@ def create_settings_for_users(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ - ("user", "0006_alter_notificationtoken_user_notificationsetting"), - ] + dependencies = [("user", "0006_alter_notificationtoken_user_notificationsetting")] operations = [ migrations.AlterField( diff --git a/backend/user/migrations/0008_remove_notificationtoken_dev.py b/backend/user/migrations/0008_remove_notificationtoken_dev.py index 693cce30..c1d6598c 100644 --- a/backend/user/migrations/0008_remove_notificationtoken_dev.py +++ b/backend/user/migrations/0008_remove_notificationtoken_dev.py @@ -5,13 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ - ("user", "0007_alter_notificationsetting_service"), - ] + dependencies = [("user", "0007_alter_notificationsetting_service")] - operations = [ - migrations.RemoveField( - model_name="notificationtoken", - name="dev", - ), - ] + operations = [migrations.RemoveField(model_name="notificationtoken", name="dev")] diff --git a/backend/user/migrations/0009_profile_fitness_preferences.py b/backend/user/migrations/0009_profile_fitness_preferences.py index ca477331..0a298b52 100644 --- a/backend/user/migrations/0009_profile_fitness_preferences.py +++ b/backend/user/migrations/0009_profile_fitness_preferences.py @@ -15,5 +15,5 @@ class Migration(migrations.Migration): model_name="profile", name="fitness_preferences", field=models.ManyToManyField(blank=True, to="penndata.FitnessRoom"), - ), + ) ] diff --git a/backend/user/models.py b/backend/user/models.py index deb3714b..538bd6e2 100644 --- a/backend/user/models.py +++ b/backend/user/models.py @@ -1,11 +1,13 @@ +from typing import Any + from django.contrib.auth import get_user_model from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver -from gsr_booking.models import Group from laundry.models import LaundryRoom from penndata.models import FitnessRoom +from utils.types import UserType User = get_user_model() @@ -16,9 +18,9 @@ class NotificationToken(models.Model): KIND_ANDROID = "ANDROID" KIND_OPTIONS = ((KIND_IOS, "iOS"), (KIND_ANDROID, "Android")) - user = models.OneToOneField(User, on_delete=models.CASCADE) - kind = models.CharField(max_length=7, choices=KIND_OPTIONS, default=KIND_IOS) - token = models.CharField(max_length=255) + user: models.OneToOneField = models.OneToOneField(User, on_delete=models.CASCADE) + kind: models.CharField = models.CharField(max_length=7, choices=KIND_OPTIONS, default=KIND_IOS) + token: models.CharField = models.CharField(max_length=255) class NotificationSetting(models.Model): @@ -49,29 +51,32 @@ class NotificationSetting(models.Model): (SERVICE_LAUNDRY, "Laundry"), ) - token = models.ForeignKey(NotificationToken, on_delete=models.CASCADE) - service = models.CharField(max_length=30, choices=SERVICE_OPTIONS, default=SERVICE_PENN_MOBILE) - enabled = models.BooleanField(default=True) + token: models.ForeignKey = models.ForeignKey(NotificationToken, on_delete=models.CASCADE) + service: models.CharField = models.CharField( + max_length=30, choices=SERVICE_OPTIONS, default=SERVICE_PENN_MOBILE + ) + enabled: models.BooleanField = models.BooleanField(default=True) class Profile(models.Model): - user = models.OneToOneField(User, on_delete=models.CASCADE) - laundry_preferences = models.ManyToManyField(LaundryRoom, blank=True) - fitness_preferences = models.ManyToManyField(FitnessRoom, blank=True) - dining_preferences = models.ManyToManyField("dining.Venue", blank=True) + user: models.OneToOneField = models.OneToOneField(User, on_delete=models.CASCADE) + laundry_preferences: models.ManyToManyField = models.ManyToManyField(LaundryRoom, blank=True) + fitness_preferences: models.ManyToManyField = models.ManyToManyField(FitnessRoom, blank=True) + dining_preferences: models.ManyToManyField = models.ManyToManyField("dining.Venue", blank=True) - def __str__(self): + def __str__(self) -> str: return str(self.user.username) @receiver(post_save, sender=User) -def create_or_update_user_profile(sender, instance, created, **kwargs): +def create_or_update_user_profile( + sender: UserType, instance: UserType, created: bool, **kwargs: Any +) -> None: """ This post_save hook triggers automatically when a User object is saved, and if no Profile object exists for that User, it will create one """ Profile.objects.get_or_create(user=instance) - Group.objects.get_or_create(owner=instance, name="Me", color="#14f7d1") # notifications token, _ = NotificationToken.objects.get_or_create(user=instance) diff --git a/backend/user/notifications.py b/backend/user/notifications.py index eacdea32..a79fe60c 100644 --- a/backend/user/notifications.py +++ b/backend/user/notifications.py @@ -1,7 +1,7 @@ import collections import os import sys -from typing import Optional +from typing import Optional, Tuple # Monkey Patch for apn2 errors, referenced from: @@ -12,34 +12,37 @@ were removed in 3.10. Specifically, the error is coming from a dependency of apns2 named hyper. """ - from collections import abc + import collections.abc as collections_abc - collections.Iterable = abc.Iterable - collections.Mapping = abc.Mapping - collections.MutableSet = abc.MutableSet - collections.MutableMapping = abc.MutableMapping + setattr(collections, "Iterable", collections_abc.Iterable) + setattr(collections, "Mapping", collections_abc.Mapping) + setattr(collections, "MutableSet", collections_abc.MutableSet) + setattr(collections, "MutableMapping", collections_abc.MutableMapping) from apns2.client import APNsClient from apns2.credentials import TokenCredentials from apns2.payload import Payload from celery import shared_task +from django.db.models import Manager, QuerySet from user.models import NotificationToken # taken from the apns2 method for batch notifications Notification = collections.namedtuple("Notification", ["token", "payload"]) +TokenPair = Tuple[str, str] # (username, token) +NotificationResult = Tuple[list[str], list[str]] # (success_users, failed_users) def send_push_notifications( users: Optional[list[str]], service: Optional[str], - title: str, + title: Optional[str], body: str, delay: int = 0, is_dev: bool = False, is_shadow: bool = False, -) -> tuple[list[str], list[str]]: +) -> NotificationResult: """ Sends push notifications. @@ -55,27 +58,31 @@ def send_push_notifications( # collect available usernames & their respective device tokens token_objects = get_tokens(users, service) if not token_objects: - return [], users + return [], users if users is not None else [] success_users, tokens = zip(*token_objects) + success_users_list = list(success_users) + tokens_list = list(tokens) # send notifications if delay: - send_delayed_notifications(tokens, title, body, service, is_dev, is_shadow, delay) + send_delayed_notifications( + tokens_list, title or "", body, service or "", is_dev, is_shadow, delay + ) else: - send_immediate_notifications(tokens, title, body, service, is_dev, is_shadow) + send_immediate_notifications(tokens_list, title, body, service or "", is_dev, is_shadow) if not users: # if to all users, can't be any failed pennkeys - return success_users, [] - failed_users = list(set(users) - set(success_users)) - return success_users, failed_users + return success_users_list, [] + failed_users = list(set(users) - set(success_users_list)) + return success_users_list, failed_users -def get_tokens( - users: Optional[list[str]] = None, service: Optional[str] = None -) -> list[tuple[str, str]]: +def get_tokens(users: Optional[list[str]] = None, service: Optional[str] = None) -> list[TokenPair]: """Returns list of token objects (with username & token value) for specified users""" - token_objs = NotificationToken.objects.select_related("user").filter( + token_objs: QuerySet[ + NotificationToken, Manager[NotificationToken] + ] = NotificationToken.objects.select_related("user").filter( kind=NotificationToken.KIND_IOS # NOTE: until Android implementation ) if users: @@ -84,7 +91,7 @@ def get_tokens( token_objs = token_objs.filter( notificationsetting__service=service, notificationsetting__enabled=True ) - return token_objs.exclude(token="").values_list("user__username", "token") + return list(token_objs.exclude(token="").values_list("user__username", "token")) @shared_task(name="notifications.send_immediate_notifications") diff --git a/backend/user/serializers.py b/backend/user/serializers.py index fce622da..e6c9ee90 100644 --- a/backend/user/serializers.py +++ b/backend/user/serializers.py @@ -56,11 +56,4 @@ class UserSerializer(serializers.ModelSerializer): class Meta: model = get_user_model() - fields = ( - "id", - "first_name", - "last_name", - "email", - "username", - "profile", - ) + fields = ("id", "first_name", "last_name", "email", "username", "profile") diff --git a/backend/user/views.py b/backend/user/views.py index 260d29d9..2b615977 100644 --- a/backend/user/views.py +++ b/backend/user/views.py @@ -1,8 +1,7 @@ -from typing import TYPE_CHECKING, Any, Optional +from typing import Optional -from django.contrib.auth import get_user_model -from django.db.models import QuerySet -from django.http import HttpResponseRedirect +from django.db.models import Manager, QuerySet +from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import get_object_or_404 from identity.permissions import B2BPermission from rest_framework import generics, viewsets @@ -19,16 +18,7 @@ NotificationTokenSerializer, UserSerializer, ) - - -if TYPE_CHECKING: - from django.contrib.auth.models import AbstractUser - - UserType = AbstractUser -else: - UserType = Any - -User = get_user_model() +from utils.types import DjangoUser, UserType, get_user class UserView(generics.RetrieveUpdateAPIView): @@ -60,7 +50,7 @@ class NotificationTokenView(viewsets.ModelViewSet): permission_classes = [IsAuthenticated] serializer_class = NotificationTokenSerializer - def get_queryset(self) -> QuerySet[NotificationToken]: + def get_queryset(self) -> QuerySet[NotificationToken, Manager[NotificationToken]]: return NotificationToken.objects.filter(user=self.request.user) @@ -80,9 +70,9 @@ class NotificationSettingView(viewsets.ModelViewSet): serializer_class = NotificationSettingSerializer def is_authorized(self, request: Request) -> bool: - return request.user and request.user.is_authenticated + return request.user is not None and request.user.is_authenticated - def get_queryset(self) -> QuerySet[NotificationSetting]: + def get_queryset(self) -> QuerySet[NotificationSetting, Manager[NotificationSetting]]: if self.is_authorized(self.request): return NotificationSetting.objects.filter(token__user=self.request.user) return NotificationSetting.objects.none() @@ -99,9 +89,9 @@ def check(self, request: Request, pk: Optional[str] = None) -> Response: pennkey = request.GET.get("pennkey") user = ( - request.user + get_user(request) if self.is_authorized(request) - else get_object_or_404(User, username=pennkey) + else get_object_or_404(DjangoUser, username=pennkey) ) token = NotificationToken.objects.filter(user=user).first() @@ -121,13 +111,13 @@ class NotificationAlertView(APIView): def post(self, request: Request) -> Response: users = ( - [self.request.user.username] - if request.user and request.user.is_authenticated + [get_user(self.request).username] + if get_user(request) and get_user(request).is_authenticated else request.data.get("users", list()) ) service = request.data.get("service") - title = request.data.get("title") - body = request.data.get("body") + title = request.data.get("title", None) + body = request.data.get("body", None) delay = max(request.data.get("delay", 0), 0) is_dev = request.data.get("is_dev", False) @@ -149,7 +139,7 @@ class ClearCookiesView(APIView): Clears all cookies from the browser """ - def get(self, request: Request) -> Response: + def get(self, request: Request) -> HttpResponse: next_url = request.GET.get("next", None) response = ( HttpResponseRedirect(f"/api/accounts/login/?next={next_url}") diff --git a/backend/utils/email.py b/backend/utils/email.py index c8608cc6..b44d2869 100644 --- a/backend/utils/email.py +++ b/backend/utils/email.py @@ -1,16 +1,22 @@ +from typing import Optional + from celery import shared_task -from django.contrib.auth.models import Group from django.core.mail import send_mail as django_send_mail from django.template.loader import get_template @shared_task(name="utils.send_mail") -def send_mail(subject, recipient_list, message=None, html_message=None): +def send_mail( + subject: str, + recipient_list: Optional[list[str]] = None, + message: Optional[str] = None, + html_message: Optional[str] = None, +) -> int: if recipient_list is None: raise ValueError("Recipient list cannot be None") success = django_send_mail( subject=subject, - message=message, + message=message, # type: ignore[arg-type] from_email=None, recipient_list=recipient_list, fail_silently=False, @@ -21,17 +27,24 @@ def send_mail(subject, recipient_list, message=None, html_message=None): @shared_task(name="utils.send_automated_email") -def send_automated_email(subject, recipient_list, message): +def send_automated_email( + subject: str, recipient_list: Optional[list[str]] = None, message: Optional[str] = None +) -> bool: template = get_template("email.html") html_message = template.render({"message": message}) return send_mail(subject, recipient_list, html_message=html_message) -def get_backend_manager_emails(): - if group := Group.objects.filter(name="backend_managers").first(): - return list( - group.user_set.exclude(email="") - .exclude(email__isnull=True) - .values_list("email", flat=True) - ) - return [] +def get_backend_manager_emails() -> list[str]: + from django.contrib.auth.models import Group + + try: + if group := Group.objects.filter(name="backend_managers").first(): + return list( + group.user_set.exclude(email="") + .exclude(email__isnull=True) + .values_list("email", flat=True) + ) + return [] + except Group.DoesNotExist: + return [] diff --git a/backend/utils/r_request.py b/backend/utils/r_request.py index 3df7e360..0ae4f96f 100644 --- a/backend/utils/r_request.py +++ b/backend/utils/r_request.py @@ -60,7 +60,7 @@ def request( verify: Optional[bool] = None, cert: Optional[str] = None, json: Optional[dict] = None, - ): + ) -> Response: response = self.__default_response() for _ in range(self.num_retries): @@ -93,7 +93,7 @@ def request( return response if not response.content: - response.content = "RRequest: Default Error" + response._content = b"RRequest: Default Error" return response diff --git a/backend/utils/types.py b/backend/utils/types.py new file mode 100644 index 00000000..893bfdb1 --- /dev/null +++ b/backend/utils/types.py @@ -0,0 +1,75 @@ +from typing import Any, Optional, Protocol, Type, TypeAlias, TypeVar, cast, runtime_checkable + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AbstractBaseUser +from django.db.models import Manager, QuerySet +from rest_framework.request import Request + + +# Get the actual User model +DjangoUser = get_user_model() + + +class UserManager(Protocol): + def create_user( + self, + username: str, + email: Optional[str] = None, + password: Optional[str] = None, + **kwargs: Any + ) -> "UserType": ... + + def create_superuser(self, username: str, email: str, password: str) -> "UserType": ... + + def get(self, **kwargs: Any) -> "UserType": ... + + def filter(self, **kwargs: Any) -> QuerySet["UserType", Manager["UserType"]]: ... + + def all(self) -> QuerySet["UserType", Manager["UserType"]]: ... + + +@runtime_checkable +class DjangoUserType(Protocol): + """Protocol defining the interface of our Django User""" + + objects: UserManager + is_superuser: bool + id: int + username: str + email: str + is_authenticated: bool + is_active: bool + is_staff: bool + date_joined: Any + last_login: Any + password: str + + def check_password(self, raw_password: str) -> bool: ... + + def set_password(self, raw_password: str) -> None: ... + + def save(self, *args: Any, **kwargs: Any) -> None: ... + + def get_username(self) -> str: ... + + +DjangoUserInstance = TypeVar("DjangoUserInstance", bound=AbstractBaseUser) +UserType: TypeAlias = DjangoUserInstance +DjangoUserModel: Type[DjangoUserType] = cast(Type[DjangoUserType], get_user_model()) + + +# Type for authenticated Django user requests +class AuthRequest(Request): + user: UserType + + +def get_auth_user(request: Request) -> DjangoUserType: + if not request.user.is_authenticated: + from rest_framework.exceptions import NotAuthenticated + + raise NotAuthenticated() + return cast(DjangoUserType, request.user) + + +def get_user(request: Request) -> UserType: + return cast(UserType, request.user)