diff --git a/backend/.env.example b/backend/.env.example index e64165ed0..8ac5c6bad 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -4,6 +4,9 @@ DJANGO_ALGOLIA_WRITE_API_KEY=None DJANGO_ALLOWED_HOSTS=* DJANGO_AWS_ACCESS_KEY_ID=None DJANGO_AWS_SECRET_ACCESS_KEY=None +DJANGO_AWS_STORAGE_BUCKET_NAME=None +DJANGO_AWS_S3_REGION_NAME=None +DJANGO_FEEDBACK_SHEET_KEY=None DJANGO_CONFIGURATION=Test DJANGO_DB_HOST=None DJANGO_DB_NAME=None diff --git a/backend/apps/feedback/__init__.py b/backend/apps/feedback/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/apps/feedback/admin.py b/backend/apps/feedback/admin.py new file mode 100644 index 000000000..846f6b406 --- /dev/null +++ b/backend/apps/feedback/admin.py @@ -0,0 +1 @@ +# Register your models here. diff --git a/backend/apps/feedback/api/__init__.py b/backend/apps/feedback/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/apps/feedback/api/feedback.py b/backend/apps/feedback/api/feedback.py new file mode 100644 index 000000000..a9a1d11db --- /dev/null +++ b/backend/apps/feedback/api/feedback.py @@ -0,0 +1,95 @@ +"""Handle feedback submission, saving to local DB and uploading to S3.""" + +import csv +from datetime import datetime, timezone +from io import StringIO + +import boto3 +import botocore +from django.conf import settings +from django.core.exceptions import ValidationError +from rest_framework import status, viewsets +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + + +class FeedbackViewSet(viewsets.ModelViewSet): + """ViewSet for handling feedback.""" + + permission_classes = [AllowAny] + + def create(self, request): + """Handle POST request for feedback submission.""" + try: + s3_client = self._get_s3_client() + output, writer = self._get_or_create_tsv(s3_client) + self._write_feedback_to_tsv(writer, request.data) + self._upload_tsv_to_s3(s3_client, output) + return Response(status=status.HTTP_201_CREATED) + except ValidationError: + return Response({"error": "Invalid Credentials"}, status=status.HTTP_400_BAD_REQUEST) + + def _get_s3_client(self): + """Initialize and returns the S3 client.""" + return boto3.client( + "s3", + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + region_name=settings.AWS_S3_REGION_NAME, + ) + + def _get_or_create_tsv(self, s3_client, tsv_key="feedbacks.tsv"): + """Get the existing TSV file or creates a new one if it doesn't exist.""" + output = StringIO() + writer = csv.writer(output, delimiter="\t") + + try: + response = s3_client.get_object( + Bucket=settings.AWS_STORAGE_BUCKET_NAME, + Key=tsv_key, + ) + # read the content from the body of the response + existing_content = response["Body"].read() + # decode the content to utf-8 format + decoded_content = existing_content.decode("utf-8") + # write the decoded content to the output file + output.write(decoded_content) + # move the cursor to the end of the file + output.seek(0, 2) + except botocore.exceptions.ClientError as e: + if e.response["Error"]["Code"] == "NoSuchKey": + writer.writerow( + ["Name", "Email", "Message", "is_anonymous", "is_nestbot", "created_at"] + ) + return output, writer + + def _write_feedback_to_tsv(self, writer, feedback_data): + """Write the new feedback data to the TSV file.""" + writer.writerow( + ( + feedback_data["name"], + feedback_data["email"], + feedback_data["message"], + feedback_data["is_anonymous"], + feedback_data["is_nestbot"], + datetime.now(timezone.utc).isoformat(), + ) + ) + + def _upload_tsv_to_s3(self, s3_client, output): + """Upload the updated TSV file back to S3.""" + output.seek(0) + s3_client.put_object( + Bucket=settings.AWS_STORAGE_BUCKET_NAME, + Key="feedbacks.tsv", + Body=output.getvalue(), + ContentType="text/tab-separated-values", + ) + + def write_feedback_to_tsv(self, writer, feedback_data): + """Public method to write feedback data to TSV format.""" + self._write_feedback_to_tsv(writer, feedback_data) + + def get_s3_client(self): + """Public method to get the S3 client.""" + return self._get_s3_client() diff --git a/backend/apps/feedback/api/urls.py b/backend/apps/feedback/api/urls.py new file mode 100644 index 000000000..77fd7eb3b --- /dev/null +++ b/backend/apps/feedback/api/urls.py @@ -0,0 +1,9 @@ +"""Feedback API URLs.""" + +from rest_framework import routers + +from apps.feedback.api.feedback import FeedbackViewSet + +router = routers.SimpleRouter() + +router.register(r"feedback", FeedbackViewSet, basename="feedback") diff --git a/backend/apps/feedback/apps.py b/backend/apps/feedback/apps.py new file mode 100644 index 000000000..7648db6e4 --- /dev/null +++ b/backend/apps/feedback/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class FeedbackConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.feedback" diff --git a/backend/apps/feedback/migrations/__init__.py b/backend/apps/feedback/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/apps/feedback/models/__init__.py b/backend/apps/feedback/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/apps/feedback/models/models.py b/backend/apps/feedback/models/models.py new file mode 100644 index 000000000..1641e2e1e --- /dev/null +++ b/backend/apps/feedback/models/models.py @@ -0,0 +1 @@ +"""Module contains the models for the feedback app.""" diff --git a/backend/poetry.lock b/backend/poetry.lock index 56da4788b..c8c996821 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -18,7 +18,6 @@ version = "3.11.14" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "aiohttp-3.11.14-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e2bc827c01f75803de77b134afdbf74fa74b62970eafdf190f3244931d7a5c0d"}, {file = "aiohttp-3.11.14-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e365034c5cf6cf74f57420b57682ea79e19eb29033399dd3f40de4d0171998fa"}, @@ -121,7 +120,6 @@ version = "1.3.2" description = "aiosignal: a list of registered asynchronous callbacks" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5"}, {file = "aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54"}, @@ -136,7 +134,6 @@ version = "4.16.0" description = "A fully-featured and blazing-fast Python API client to interact with Algolia." optional = false python-versions = ">=3.8.1" -groups = ["main"] files = [ {file = "algoliasearch-4.16.0-py3-none-any.whl", hash = "sha256:c462c0215cf68016914e289f084485189b1c2176814776a2571a41407954335b"}, {file = "algoliasearch-4.16.0.tar.gz", hash = "sha256:d3c0ef9c80a184f855e7de15e5d4dac64738e08de929ae9599e488280e085b81"}, @@ -156,7 +153,6 @@ version = "4.0.0" description = "Algolia Search integration for Django" optional = false python-versions = "*" -groups = ["main"] files = [ {file = "algoliasearch_django-4.0.0-py2.py3-none-any.whl", hash = "sha256:d160b86cd999607e9b3b0773a712e196e251af2b7dcb2480e40ef09440f3c80a"}, {file = "algoliasearch_django-4.0.0.tar.gz", hash = "sha256:c0acb8231163c16757d9e4c37a0ce882b89c4640a6dc836daaf479fd73c427b5"}, @@ -171,7 +167,6 @@ version = "0.7.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, @@ -183,7 +178,6 @@ version = "4.9.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, @@ -204,7 +198,6 @@ version = "3.8.1" description = "ASGI specs, helper code, and adapters" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47"}, {file = "asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590"}, @@ -219,7 +212,6 @@ version = "5.0.1" description = "Timeout context manager for asyncio programs" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, @@ -231,7 +223,6 @@ version = "25.3.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3"}, {file = "attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"}, @@ -251,7 +242,6 @@ version = "1.37.20" description = "The AWS SDK for Python" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "boto3-1.37.20-py3-none-any.whl", hash = "sha256:225dbc75d79816cb9b28cc74a63c9fa0f2d70530d603dacd82634f362f6679c1"}, {file = "boto3-1.37.20.tar.gz", hash = "sha256:87d9bd6ad49be754d4ae2724cfb892eb3f9f17bcafd781fb3ce0d98cc539bdd6"}, @@ -271,7 +261,6 @@ version = "1.37.20" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "botocore-1.37.20-py3-none-any.whl", hash = "sha256:c34f4f25fda7c4f726adf5a948590bd6bd7892c05278d31e344b5908e7b43301"}, {file = "botocore-1.37.20.tar.gz", hash = "sha256:9295385740f9d30f9b679f76ee51f49b80ae73183d84d499c1c3f1d54d820f54"}, @@ -291,7 +280,6 @@ version = "2025.1.31" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" -groups = ["main"] files = [ {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, @@ -303,7 +291,6 @@ version = "1.17.1" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, @@ -383,7 +370,6 @@ version = "3.4.0" description = "Validate configuration and produce human readable error messages." optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, @@ -395,7 +381,6 @@ version = "3.4.1" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, @@ -497,7 +482,6 @@ version = "8.1.8" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" -groups = ["dev"] files = [ {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, @@ -512,12 +496,10 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["main", "dev", "test"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {main = "platform_system == \"Windows\"", test = "sys_platform == \"win32\""} [[package]] name = "coverage" @@ -525,7 +507,6 @@ version = "7.7.1" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.9" -groups = ["test"] files = [ {file = "coverage-7.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:553ba93f8e3c70e1b0031e4dfea36aba4e2b51fe5770db35e99af8dc5c5a9dfe"}, {file = "coverage-7.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:44683f2556a56c9a6e673b583763096b8efbd2df022b02995609cf8e64fc8ae0"}, @@ -601,7 +582,6 @@ version = "44.0.2" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = "!=3.9.0,!=3.9.1,>=3.7" -groups = ["main"] files = [ {file = "cryptography-44.0.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:efcfe97d1b3c79e486554efddeb8f6f53a4cdd4cf6086642784fa31fc384e1d7"}, {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29ecec49f3ba3f3849362854b7253a9f59799e3763b0c9d0826259a88efa02f1"}, @@ -659,7 +639,6 @@ version = "1.15.4" description = "CSS unobfuscator and beautifier." optional = false python-versions = "*" -groups = ["dev"] files = [ {file = "cssbeautifier-1.15.4-py3-none-any.whl", hash = "sha256:78c84d5e5378df7d08622bbd0477a1abdbd209680e95480bf22f12d5701efc98"}, {file = "cssbeautifier-1.15.4.tar.gz", hash = "sha256:9bb08dc3f64c101a01677f128acf01905914cf406baf87434dcde05b74c0acf5"}, @@ -676,7 +655,6 @@ version = "1.2.18" description = "Python @deprecated decorator to deprecate old python classes, functions or methods." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" -groups = ["main"] files = [ {file = "Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec"}, {file = "deprecated-1.2.18.tar.gz", hash = "sha256:422b6f6d859da6f2ef57857761bfb392480502a64c3028ca9bbe86085d72115d"}, @@ -694,7 +672,6 @@ version = "0.3.9" description = "Distribution utilities" optional = false python-versions = "*" -groups = ["dev"] files = [ {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"}, {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, @@ -706,7 +683,6 @@ version = "1.9.0" description = "Distro - an OS platform information API" optional = false python-versions = ">=3.6" -groups = ["main"] files = [ {file = "distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2"}, {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"}, @@ -718,7 +694,6 @@ version = "5.1.7" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "Django-5.1.7-py3-none-any.whl", hash = "sha256:1323617cb624add820cb9611cdcc788312d250824f92ca6048fda8625514af2b"}, {file = "Django-5.1.7.tar.gz", hash = "sha256:30de4ee43a98e5d3da36a9002f287ff400b43ca51791920bfb35f6917bfe041c"}, @@ -739,7 +714,6 @@ version = "2.5.1" description = "A helper for organizing Django settings." optional = false python-versions = "<4.0,>=3.8" -groups = ["main"] files = [ {file = "django-configurations-2.5.1.tar.gz", hash = "sha256:6e5083757e2bbdf9bb7850567536b96a93515f6b17503d74928ff628db2e0e94"}, {file = "django_configurations-2.5.1-py3-none-any.whl", hash = "sha256:ceb84858da2dac846b15e715c2fd936cfc4c7917c074aff8d31700564093955e"}, @@ -761,7 +735,6 @@ version = "4.7.0" description = "django-cors-headers is a Django application for handling the server headers required for Cross-Origin Resource Sharing (CORS)." optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "django_cors_headers-4.7.0-py3-none-any.whl", hash = "sha256:f1c125dcd58479fe7a67fe2499c16ee38b81b397463cf025f0e2c42937421070"}, {file = "django_cors_headers-4.7.0.tar.gz", hash = "sha256:6fdf31bf9c6d6448ba09ef57157db2268d515d94fc5c89a0a1028e1fc03ee52b"}, @@ -792,7 +765,6 @@ version = "1.14.5" description = "Support for many storage backends in Django" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "django_storages-1.14.5-py3-none-any.whl", hash = "sha256:5ce9c69426f24f379821fd688442314e4aa03de87ae43183c4e16915f4c165d4"}, {file = "django_storages-1.14.5.tar.gz", hash = "sha256:ace80dbee311258453e30cd5cfd91096b834180ccf09bc1f4d2cb6d38d68571a"}, @@ -817,7 +789,6 @@ version = "3.15.2" description = "Web APIs for Django, made easy." optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "djangorestframework-3.15.2-py3-none-any.whl", hash = "sha256:2b8871b062ba1aefc2de01f773875441a961fefbf79f5eed1e32b2f096944b20"}, {file = "djangorestframework-3.15.2.tar.gz", hash = "sha256:36fe88cd2d6c6bec23dca9804bab2ba5517a8bb9d8f47ebc68981b56840107ad"}, @@ -832,7 +803,6 @@ version = "1.36.4" description = "HTML Template Linter and Formatter" optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "djlint-1.36.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a2dfb60883ceb92465201bfd392291a7597c6752baede6fbb6f1980cac8d6c5c"}, {file = "djlint-1.36.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4bc6a1320c0030244b530ac200642f883d3daa451a115920ef3d56d08b644292"}, @@ -875,7 +845,6 @@ version = "0.17.0" description = "EditorConfig File Locator and Interpreter for Python" optional = false python-versions = "*" -groups = ["dev"] files = [ {file = "EditorConfig-0.17.0-py3-none-any.whl", hash = "sha256:fe491719c5f65959ec00b167d07740e7ffec9a3f362038c72b289330b9991dfc"}, {file = "editorconfig-0.17.0.tar.gz", hash = "sha256:8739052279699840065d3a9f5c125d7d5a98daeefe53b0e5274261d77cb49aa2"}, @@ -887,7 +856,6 @@ version = "2.1.1" description = "execnet: rapid multi-Python deployment" optional = false python-versions = ">=3.8" -groups = ["test"] files = [ {file = "execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc"}, {file = "execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3"}, @@ -902,7 +870,6 @@ version = "3.18.0" description = "A platform independent file lock." optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de"}, {file = "filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2"}, @@ -919,7 +886,6 @@ version = "1.5.0" description = "A list-like structure which implements collections.abc.MutableSequence" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "frozenlist-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5b6a66c18b5b9dd261ca98dffcb826a525334b2f29e7caa54e182255c5f6a65a"}, {file = "frozenlist-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d1b3eb7b05ea246510b43a7e53ed1653e55c2121019a97e60cad7efb881a97bb"}, @@ -1021,7 +987,6 @@ version = "2.0" description = "The geodesic routines from GeographicLib" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "geographiclib-2.0-py3-none-any.whl", hash = "sha256:6b7225248e45ff7edcee32becc4e0a1504c606ac5ee163a5656d482e0cd38734"}, {file = "geographiclib-2.0.tar.gz", hash = "sha256:f7f41c85dc3e1c2d3d935ec86660dc3b2c848c83e17f9a9e51ba9d5146a15859"}, @@ -1033,7 +998,6 @@ version = "2.4.1" description = "Python Geocoding Toolbox" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "geopy-2.4.1-py3-none-any.whl", hash = "sha256:ae8b4bc5c1131820f4d75fce9d4aaaca0c85189b3aa5d64c3dcaf5e3b7b882a7"}, {file = "geopy-2.4.1.tar.gz", hash = "sha256:50283d8e7ad07d89be5cb027338c6365a32044df3ae2556ad3f52f4840b3d0d1"}, @@ -1057,7 +1021,6 @@ version = "3.4.3" description = "GraphQL Framework for Python" optional = false python-versions = "*" -groups = ["main"] files = [ {file = "graphene-3.4.3-py2.py3-none-any.whl", hash = "sha256:820db6289754c181007a150db1f7fff544b94142b556d12e3ebc777a7bf36c71"}, {file = "graphene-3.4.3.tar.gz", hash = "sha256:2a3786948ce75fe7e078443d37f609cbe5bb36ad8d6b828740ad3b95ed1a0aaa"}, @@ -1079,7 +1042,6 @@ version = "3.2.3" description = "Graphene Django integration" optional = false python-versions = "*" -groups = ["main"] files = [ {file = "graphene-django-3.2.3.tar.gz", hash = "sha256:d831bfe8e9a6e77e477b7854faef4addb318f386119a69ee4c57b74560f3e07d"}, {file = "graphene_django-3.2.3-py2.py3-none-any.whl", hash = "sha256:0c673a4dad315b26b4d18eb379ad0c7027fd6a36d23a1848b7c7c09a14a9271e"}, @@ -1104,7 +1066,6 @@ version = "3.2.6" description = "GraphQL implementation for Python, a port of GraphQL.js, the JavaScript reference implementation for GraphQL." optional = false python-versions = "<4,>=3.6" -groups = ["main"] files = [ {file = "graphql_core-3.2.6-py3-none-any.whl", hash = "sha256:78b016718c161a6fb20a7d97bbf107f331cd1afe53e45566c59f776ed7f0b45f"}, {file = "graphql_core-3.2.6.tar.gz", hash = "sha256:c08eec22f9e40f0bd61d805907e3b3b1b9a320bc606e23dc145eebca07c8fbab"}, @@ -1116,7 +1077,6 @@ version = "3.2.0" description = "Relay library for graphql-core" optional = false python-versions = ">=3.6,<4" -groups = ["main"] files = [ {file = "graphql-relay-3.2.0.tar.gz", hash = "sha256:1ff1c51298356e481a0be009ccdff249832ce53f30559c1338f22a0e0d17250c"}, {file = "graphql_relay-3.2.0-py3-none-any.whl", hash = "sha256:c9b22bd28b170ba1fe674c74384a8ff30a76c8e26f88ac3aa1584dd3179953e5"}, @@ -1131,7 +1091,6 @@ version = "23.0.0" description = "WSGI HTTP Server for UNIX" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d"}, {file = "gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec"}, @@ -1153,7 +1112,6 @@ version = "0.14.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, @@ -1165,7 +1123,6 @@ version = "1.0.7" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd"}, {file = "httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c"}, @@ -1187,7 +1144,6 @@ version = "0.28.1" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, @@ -1212,7 +1168,6 @@ version = "4.12.2" description = "Python humanize utilities" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "humanize-4.12.2-py3-none-any.whl", hash = "sha256:e4e44dced598b7e03487f3b1c6fd5b1146c30ea55a110e71d5d4bca3e094259e"}, {file = "humanize-4.12.2.tar.gz", hash = "sha256:ce0715740e9caacc982bb89098182cf8ded3552693a433311c6a4ce6f4e12a2c"}, @@ -1227,7 +1182,6 @@ version = "2.6.9" description = "File identification library for Python" optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "identify-2.6.9-py2.py3-none-any.whl", hash = "sha256:c98b4322da415a8e5a70ff6e51fbc2d2932c015532d77e9f8537b4ba7813b150"}, {file = "identify-2.6.9.tar.gz", hash = "sha256:d40dfe3142a1421d8518e3d3985ef5ac42890683e32306ad614a29490abeb6bf"}, @@ -1242,7 +1196,6 @@ version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" -groups = ["main"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -1269,7 +1222,6 @@ version = "0.9.0" description = "Fast iterable JSON parser." optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "jiter-0.9.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:816ec9b60fdfd1fec87da1d7ed46c66c44ffec37ab2ef7de5b147b2fce3fd5ad"}, {file = "jiter-0.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9b1d3086f8a3ee0194ecf2008cf81286a5c3e540d977fa038ff23576c023c0ea"}, @@ -1355,7 +1307,6 @@ version = "1.0.1" description = "JSON Matching Expressions" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980"}, {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, @@ -1367,7 +1318,6 @@ version = "1.15.4" description = "JavaScript unobfuscator and beautifier." optional = false python-versions = "*" -groups = ["dev"] files = [ {file = "jsbeautifier-1.15.4-py3-none-any.whl", hash = "sha256:72f65de312a3f10900d7685557f84cb61a9733c50dcc27271a39f5b0051bf528"}, {file = "jsbeautifier-1.15.4.tar.gz", hash = "sha256:5bb18d9efb9331d825735fbc5360ee8f1aac5e52780042803943aa7f854f7592"}, @@ -1383,7 +1333,6 @@ version = "0.10.0" description = "A Python implementation of the JSON5 data format." optional = false python-versions = ">=3.8.0" -groups = ["dev"] files = [ {file = "json5-0.10.0-py3-none-any.whl", hash = "sha256:19b23410220a7271e8377f81ba8aacba2fdd56947fbb137ee5977cbe1f5e8dfa"}, {file = "json5-0.10.0.tar.gz", hash = "sha256:e66941c8f0a02026943c52c2eb34ebeb2a6f819a0be05920a6f5243cd30fd559"}, @@ -1398,7 +1347,6 @@ version = "5.3.1" description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." optional = false python-versions = ">=3.6" -groups = ["main"] files = [ {file = "lxml-5.3.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a4058f16cee694577f7e4dd410263cd0ef75644b43802a689c2b3c2a7e69453b"}, {file = "lxml-5.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:364de8f57d6eda0c16dcfb999af902da31396949efa0e583e12675d09709881b"}, @@ -1553,7 +1501,6 @@ version = "3.7" description = "Python implementation of John Gruber's Markdown." optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "Markdown-3.7-py3-none-any.whl", hash = "sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803"}, {file = "markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2"}, @@ -1671,7 +1618,6 @@ version = "1.9.1" description = "Node.js virtual environment builder" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev"] files = [ {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, @@ -1683,7 +1629,6 @@ version = "1.68.2" description = "The official Python library for the openai API" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "openai-1.68.2-py3-none-any.whl", hash = "sha256:24484cb5c9a33b58576fdc5acf0e5f92603024a4e39d0b99793dfa1eb14c2b36"}, {file = "openai-1.68.2.tar.gz", hash = "sha256:b720f0a95a1dbe1429c0d9bb62096a0d98057bcda82516f6e8af10284bdd5b19"}, @@ -1710,7 +1655,6 @@ version = "24.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["main", "test"] files = [ {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, @@ -1722,7 +1666,6 @@ version = "0.12.1" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, @@ -1751,7 +1694,6 @@ version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" -groups = ["test"] files = [ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, @@ -1767,7 +1709,6 @@ version = "4.2.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd"}, {file = "pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146"}, @@ -1786,7 +1727,6 @@ version = "2.3" description = "Promises/A+ implementation for Python" optional = false python-versions = "*" -groups = ["main"] files = [ {file = "promise-2.3.tar.gz", hash = "sha256:dfd18337c523ba4b6a58801c164c1904a9d4d1b1747c7d5dbf45b693a49d93d0"}, ] @@ -1803,7 +1743,6 @@ version = "0.3.1" description = "Accelerated property cache" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "propcache-0.3.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f27785888d2fdd918bc36de8b8739f2d6c791399552333721b58193f68ea3e98"}, {file = "propcache-0.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4e89cde74154c7b5957f87a355bb9c8ec929c167b59c83d90654ea36aeb6180"}, @@ -1911,7 +1850,6 @@ version = "3.2.6" description = "PostgreSQL database adapter for Python" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "psycopg-3.2.6-py3-none-any.whl", hash = "sha256:f3ff5488525890abb0566c429146add66b329e20d6d4835662b920cbbf90ac58"}, {file = "psycopg-3.2.6.tar.gz", hash = "sha256:16fa094efa2698f260f2af74f3710f781e4a6f226efe9d1fd0c37f384639ed8a"}, @@ -1934,7 +1872,6 @@ version = "2.22" description = "C parser in Python" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, @@ -1946,7 +1883,6 @@ version = "2.10.6" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584"}, {file = "pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236"}, @@ -1967,7 +1903,6 @@ version = "2.27.2" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa"}, {file = "pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c"}, @@ -2080,7 +2015,6 @@ version = "2.6.1" description = "Use the full Github API v3" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "PyGithub-2.6.1-py3-none-any.whl", hash = "sha256:6f2fa6d076ccae475f9fc392cc6cdbd54db985d4f69b8833a28397de75ed6ca3"}, {file = "pygithub-2.6.1.tar.gz", hash = "sha256:b5c035392991cca63959e9453286b41b54d83bf2de2daa7d7ff7e4312cebf3bf"}, @@ -2100,7 +2034,6 @@ version = "2.10.1" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, @@ -2121,7 +2054,6 @@ version = "1.5.0" description = "Python binding to the Networking and Cryptography (NaCl) library" optional = false python-versions = ">=3.6" -groups = ["main"] files = [ {file = "PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1"}, {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92"}, @@ -2148,7 +2080,6 @@ version = "8.3.5" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" -groups = ["test"] files = [ {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"}, {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"}, @@ -2169,7 +2100,6 @@ version = "6.0.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.9" -groups = ["test"] files = [ {file = "pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0"}, {file = "pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35"}, @@ -2188,7 +2118,6 @@ version = "4.10.0" description = "A Django plugin for pytest." optional = false python-versions = ">=3.8" -groups = ["test"] files = [ {file = "pytest_django-4.10.0-py3-none-any.whl", hash = "sha256:57c74ef3aa9d89cae5a5d73fbb69a720a62673ade7ff13b9491872409a3f5918"}, {file = "pytest_django-4.10.0.tar.gz", hash = "sha256:1091b20ea1491fd04a310fc9aaff4c01b4e8450e3b157687625e16a6b5f3a366"}, @@ -2207,7 +2136,6 @@ version = "3.14.0" description = "Thin-wrapper around the mock package for easier use with pytest" optional = false python-versions = ">=3.8" -groups = ["test"] files = [ {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, @@ -2225,7 +2153,6 @@ version = "3.6.1" description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" optional = false python-versions = ">=3.8" -groups = ["test"] files = [ {file = "pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7"}, {file = "pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d"}, @@ -2246,7 +2173,6 @@ version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main"] files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -2276,7 +2202,6 @@ version = "6.0.2" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] files = [ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, @@ -2339,7 +2264,6 @@ version = "2024.11.6" description = "Alternative regular expression module, to replace re." optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff590880083d60acc0433f9c3f713c51f7ac6ebb9adf889c79a261ecf541aa91"}, {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:658f90550f38270639e83ce492f27d2c8d2cd63805c65a13a14d36ca126753f0"}, @@ -2443,7 +2367,6 @@ version = "2.32.3" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, @@ -2465,7 +2388,6 @@ version = "0.11.2" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" -groups = ["dev"] files = [ {file = "ruff-0.11.2-py3-none-linux_armv6l.whl", hash = "sha256:c69e20ea49e973f3afec2c06376eb56045709f0212615c1adb0eda35e8a4e477"}, {file = "ruff-0.11.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2c5424cc1c4eb1d8ecabe6d4f1b70470b4f24a0c0171356290b1953ad8f0e272"}, @@ -2493,7 +2415,6 @@ version = "0.11.4" description = "An Amazon S3 Transfer Manager" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "s3transfer-0.11.4-py3-none-any.whl", hash = "sha256:ac265fa68318763a03bf2dc4f39d5cbd6a9e178d81cc9483ad27da33637e320d"}, {file = "s3transfer-0.11.4.tar.gz", hash = "sha256:559f161658e1cf0a911f45940552c696735f5c74e64362e515f333ebed87d679"}, @@ -2511,7 +2432,6 @@ version = "2.24.1" description = "Python client for Sentry (https://sentry.io)" optional = false python-versions = ">=3.6" -groups = ["main"] files = [ {file = "sentry_sdk-2.24.1-py2.py3-none-any.whl", hash = "sha256:36baa6a1128b9d98d2adc5e9b2f887eff0a6af558fc2b96ed51919042413556d"}, {file = "sentry_sdk-2.24.1.tar.gz", hash = "sha256:8ba3c29990fa48865b908b3b9dc5ae7fa7e72407c7c9e91303e5206b32d7b8b1"}, @@ -2569,7 +2489,6 @@ version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main", "dev"] files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, @@ -2581,7 +2500,6 @@ version = "1.23.0" description = "The Bolt Framework for Python" optional = false python-versions = ">=3.6" -groups = ["main"] files = [ {file = "slack_bolt-1.23.0-py2.py3-none-any.whl", hash = "sha256:6d6ae39d80c964c362505ae4e587eed2b26dbc3a9f0cb76af1150c30fb670488"}, {file = "slack_bolt-1.23.0.tar.gz", hash = "sha256:3d2c3eb13131407a94f925eb22b180d352c2d97b808303ef92b7a46d6508c843"}, @@ -2596,7 +2514,6 @@ version = "3.35.0" description = "The Slack API Platform SDK for Python" optional = false python-versions = ">=3.6" -groups = ["main"] files = [ {file = "slack_sdk-3.35.0-py2.py3-none-any.whl", hash = "sha256:00933d171fbd8a068b321ebb5f89612cc781d3183d8e3447c85499eca9d865be"}, {file = "slack_sdk-3.35.0.tar.gz", hash = "sha256:8183b6cbf26a0c1e2441478cd9c0dc4eef08d60c1394cfdc9a769e309a9b6459"}, @@ -2611,7 +2528,6 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -2623,7 +2539,6 @@ version = "0.5.3" description = "A non-validating SQL parser." optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca"}, {file = "sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272"}, @@ -2639,7 +2554,6 @@ version = "1.3" description = "The most basic Text::Unidecode port" optional = false python-versions = "*" -groups = ["main"] files = [ {file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"}, {file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"}, @@ -2651,7 +2565,6 @@ version = "4.67.1" description = "Fast, Extensible Progress Meter" optional = false python-versions = ">=3.7" -groups = ["main", "dev"] files = [ {file = "tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2"}, {file = "tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2"}, @@ -2673,7 +2586,6 @@ version = "4.13.0" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "typing_extensions-4.13.0-py3-none-any.whl", hash = "sha256:c8dd92cc0d6425a97c18fbb9d1954e5ff92c1ca881a309c45f06ebc0b79058e5"}, {file = "typing_extensions-4.13.0.tar.gz", hash = "sha256:0a4ac55a5820789d87e297727d229866c9650f6521b64206413c4fbada24d95b"}, @@ -2685,8 +2597,6 @@ version = "2025.2" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" -groups = ["main"] -markers = "sys_platform == \"win32\"" files = [ {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, @@ -2698,7 +2608,6 @@ version = "2.3.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df"}, {file = "urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d"}, @@ -2716,7 +2625,6 @@ version = "20.29.3" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "virtualenv-20.29.3-py3-none-any.whl", hash = "sha256:3e3d00f5807e83b234dfb6122bf37cfadf4be216c53a49ac059d02414f819170"}, {file = "virtualenv-20.29.3.tar.gz", hash = "sha256:95e39403fcf3940ac45bc717597dba16110b74506131845d9b687d5e73d947ac"}, @@ -2737,7 +2645,6 @@ version = "1.17.2" description = "Module for decorators, wrappers and monkey patching." optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "wrapt-1.17.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3d57c572081fed831ad2d26fd430d565b76aa277ed1d30ff4d40670b1c0dd984"}, {file = "wrapt-1.17.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b5e251054542ae57ac7f3fba5d10bfff615b6c2fb09abeb37d2f1463f841ae22"}, @@ -2826,7 +2733,6 @@ version = "1.18.3" description = "Yet another URL library" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "yarl-1.18.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7df647e8edd71f000a5208fe6ff8c382a1de8edfbccdbbfe649d263de07d8c34"}, {file = "yarl-1.18.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c69697d3adff5aa4f874b19c0e4ed65180ceed6318ec856ebc423aa5850d84f7"}, @@ -2918,6 +2824,6 @@ multidict = ">=4.0" propcache = ">=0.2.0" [metadata] -lock-version = "2.1" +lock-version = "2.0" python-versions = "^3.13" content-hash = "defcb17629ba4de68195b11720e61b7a084f226cf1a4e4005d186010dea5012b" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 5a6bbd2db..dd1bedd8c 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -44,6 +44,7 @@ pyyaml = "^6.0.2" requests = "^2.32.3" sentry-sdk = { extras = ["django"], version = "^2.20.0" } slack-bolt = "^1.22.0" +boto3 = "^1.36.1" [tool.poetry.group.dev.dependencies] djlint = "^1.36.4" diff --git a/backend/settings/base.py b/backend/settings/base.py index 64b128936..fec51b754 100644 --- a/backend/settings/base.py +++ b/backend/settings/base.py @@ -44,6 +44,7 @@ class Base(Configuration): LOCAL_APPS = ( "apps.common", "apps.core", + "apps.feedback", "apps.github", "apps.owasp", "apps.slack", @@ -122,6 +123,21 @@ class Base(Configuration): "INDEX_PREFIX": ENVIRONMENT.lower(), } + # AWS S3 Configuration + AWS_ACCESS_KEY_ID = values.SecretValue(environ_name="AWS_ACCESS_KEY_ID") + AWS_SECRET_ACCESS_KEY = values.SecretValue(environ_name="AWS_SECRET_ACCESS_KEY") + AWS_STORAGE_BUCKET_NAME = values.SecretValue(environ_name="AWS_STORAGE_BUCKET_NAME") + AWS_S3_REGION_NAME = values.SecretValue(environ_name="AWS_S3_REGION_NAME") + FEEDBACK_SHEET_KEY = values.SecretValue(environ_name="FEEDBACK_SHEET_KEY") + + AWS = { + "ACCESS_KEY_ID": AWS_ACCESS_KEY_ID, + "SECRET_ACCESS_KEY": AWS_SECRET_ACCESS_KEY, + "STORAGE_BUCKET_NAME": AWS_STORAGE_BUCKET_NAME, + "S3_REGION_NAME": AWS_S3_REGION_NAME, + "FEEDBACK_SHEET_KEY": FEEDBACK_SHEET_KEY, + } + CACHES = { "default": { "BACKEND": "django.core.cache.backends.locmem.LocMemCache", diff --git a/backend/settings/urls.py b/backend/settings/urls.py index 845a8b41d..3be6af003 100644 --- a/backend/settings/urls.py +++ b/backend/settings/urls.py @@ -13,6 +13,7 @@ from rest_framework import routers from apps.core.api.algolia import algolia_search +from apps.feedback.api.urls import router as feedback_router from apps.github.api.urls import router as github_router from apps.owasp.api.urls import router as owasp_router from apps.slack.apps import SlackConfig @@ -20,6 +21,7 @@ router = routers.DefaultRouter() router.registry.extend(github_router.registry) router.registry.extend(owasp_router.registry) +router.registry.extend(feedback_router.registry) urlpatterns = [ path("idx/", csrf_exempt(algolia_search)), diff --git a/backend/tests/feedback/__init__.py b/backend/tests/feedback/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/tests/feedback/admin_test.py b/backend/tests/feedback/admin_test.py new file mode 100644 index 000000000..846f6b406 --- /dev/null +++ b/backend/tests/feedback/admin_test.py @@ -0,0 +1 @@ +# Register your models here. diff --git a/backend/tests/feedback/api/__init__.py b/backend/tests/feedback/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/tests/feedback/api/feedback_test.py b/backend/tests/feedback/api/feedback_test.py new file mode 100644 index 000000000..b765e440e --- /dev/null +++ b/backend/tests/feedback/api/feedback_test.py @@ -0,0 +1,99 @@ +import csv +from datetime import datetime, timezone +from io import StringIO +from unittest.mock import Mock, patch + +import botocore +import pytest +from django.conf import settings +from rest_framework import status + +from apps.feedback.api.feedback import FeedbackViewSet + + +@pytest.fixture() +def feedback_viewset(): + return FeedbackViewSet() + + +@pytest.fixture() +def valid_feedback_data(): + return { + "name": "John Doe", + "email": "john@example.com", + "message": "Test feedback", + "is_anonymous": False, + "is_nestbot": False, + } + + +@pytest.fixture() +def mock_s3_client(): + with patch("boto3.client") as mock_client: + mock_client.return_value.exceptions.NoSuchKey = botocore.exceptions.ClientError( + {"Error": {"Code": "NoSuchKey", "Message": "The specified key does not exist."}}, + "GetObject", + ) + yield mock_client.return_value + + +class TestFeedbackViewSet: + def test_create_success(self, feedback_viewset, valid_feedback_data, mock_s3_client): + """Test successful feedback submission.""" + request = Mock() + request.data = valid_feedback_data + + mock_s3_client.get_object.return_value = {"Body": Mock(read=lambda: b"")} + + response = feedback_viewset.create(request) + + assert response.status_code == status.HTTP_201_CREATED + + mock_s3_client.put_object.assert_called_once() + put_call_kwargs = mock_s3_client.put_object.call_args[1] + assert put_call_kwargs["Bucket"] == settings.AWS_STORAGE_BUCKET_NAME + assert put_call_kwargs["Key"] == "feedbacks.tsv" + assert "Body" in put_call_kwargs + assert put_call_kwargs["ContentType"] == "text/tab-separated-values" + + def test_create_validation_error(self, feedback_viewset, valid_feedback_data, mock_s3_client): + """Test feedback submission with validation error.""" + request = Mock() + request.data = valid_feedback_data + + mock_s3_client.get_object.side_effect = botocore.exceptions.ClientError( + {"Error": {"Code": "ValidationError", "Message": "Invalid credentials"}}, "GetObject" + ) + + response = feedback_viewset.create(request) + + assert response.status_code == status.HTTP_201_CREATED + + def test_write_feedback_to_tsv(self, feedback_viewset, valid_feedback_data): + """Test writing feedback data to TSV format.""" + output = StringIO() + writer = csv.writer(output, delimiter="\t") + + current_time = datetime(2025, 1, 22, 10, 45, 34, 567884, tzinfo=timezone.utc) + with patch("django.utils.timezone.now", return_value=current_time): + feedback_viewset.write_feedback_to_tsv(writer, valid_feedback_data) + + output.seek(0) + written_data = output.getvalue().strip().split("\t") + assert written_data[0] == valid_feedback_data["name"] + assert written_data[1] == valid_feedback_data["email"] + assert written_data[2] == valid_feedback_data["message"] + assert written_data[3] == str(valid_feedback_data["is_anonymous"]) + assert written_data[4] == str(valid_feedback_data["is_nestbot"]) + + @patch("boto3.client") + def test_get_s3_client(self, mock_boto3, feedback_viewset): + """Test S3 client initialization.""" + feedback_viewset.get_s3_client() + + mock_boto3.assert_called_once_with( + "s3", + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + region_name=settings.AWS_S3_REGION_NAME, + ) diff --git a/backend/tests/feedback/api/urls_test.py b/backend/tests/feedback/api/urls_test.py new file mode 100644 index 000000000..59e0bbe8e --- /dev/null +++ b/backend/tests/feedback/api/urls_test.py @@ -0,0 +1,25 @@ +import pytest + +from apps.feedback.api.feedback import FeedbackViewSet +from apps.feedback.api.urls import router + + +@pytest.mark.parametrize( + ("url_name", "expected_prefix", "viewset_class"), + [ + ("feedback-list", "feedback", FeedbackViewSet), + ], +) +def test_router_registration(url_name, expected_prefix, viewset_class): + matching_routes = [route for route in router.urls if route.name == url_name] + assert matching_routes, f"Route '{url_name}' not found in router." + + for route in matching_routes: + assert ( + expected_prefix in route.pattern.describe() + ), f"Prefix '{expected_prefix}' not found in route '{route.name}'." + + viewset = route.callback.cls + assert issubclass( + viewset, viewset_class + ), f"Viewset for '{route.name}' does not match {viewset_class}." diff --git a/backend/tests/feedback/apps_test.py b/backend/tests/feedback/apps_test.py new file mode 100644 index 000000000..f29268733 --- /dev/null +++ b/backend/tests/feedback/apps_test.py @@ -0,0 +1,10 @@ +from django.apps import apps +from django.test import SimpleTestCase + +from apps.feedback.apps import FeedbackConfig + + +class FeedbackConfigTests(SimpleTestCase): + def test_apps(self): + assert FeedbackConfig.name == "apps.feedback" + assert apps.get_app_config("feedback").name == "apps.feedback" diff --git a/backend/tests/feedback/models/__init__.py b/backend/tests/feedback/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/tests/feedback/models/models_test.py b/backend/tests/feedback/models/models_test.py new file mode 100644 index 000000000..1641e2e1e --- /dev/null +++ b/backend/tests/feedback/models/models_test.py @@ -0,0 +1 @@ +"""Module contains the models for the feedback app.""" diff --git a/frontend/.env.example b/frontend/.env.example index 854da1f6b..7f19fa5ad 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -3,6 +3,8 @@ VITE_ENVIRONMENT=local VITE_GRAPHQL_URL=http://localhost:8000/graphql/ VITE_GTM_AUTH=your-google-tag-manager-auth VITE_GTM_ID=your-google-tag-manager-id +VITE_RECAPTCHA_SECRET_KEY= +VITE_RECAPTCHA_SITE_KEY= VITE_GTM_PREVIEW= VITE_IDX_URL=http://localhost:8000/idx/ VITE_RELEASE_VERSION= diff --git a/frontend/__tests__/unit/App.test.tsx b/frontend/__tests__/unit/App.test.tsx index 87ba96bd5..6920a6442 100644 --- a/frontend/__tests__/unit/App.test.tsx +++ b/frontend/__tests__/unit/App.test.tsx @@ -18,6 +18,7 @@ jest.mock('pages', () => ({ SnapshotDetailsPage: () =>
SnapshotDetails Page
, UserDetailsPage: () =>
UserDetails Page
, UsersPage: () =>
Users Page
, + FeedbackPage: () =>
Feedback Page
, })) beforeAll(() => { diff --git a/frontend/__tests__/unit/pages/ChapterDetails.test.tsx b/frontend/__tests__/unit/pages/ChapterDetails.test.tsx index 4a24f3611..6881ddf20 100644 --- a/frontend/__tests__/unit/pages/ChapterDetails.test.tsx +++ b/frontend/__tests__/unit/pages/ChapterDetails.test.tsx @@ -3,6 +3,9 @@ import { screen, waitFor } from '@testing-library/react' import { mockChapterDetailsData } from '@unit/data/mockChapterDetailsData' import { ChapterDetailsPage } from 'pages' import { render } from 'wrappers/testUtil' +global.structuredClone = (val) => JSON.parse(JSON.stringify(val)) + +jest.mock('api/fetchAlgoliaData') jest.mock('@apollo/client', () => ({ ...jest.requireActual('@apollo/client'), diff --git a/frontend/__tests__/unit/pages/Feedback.test.tsx b/frontend/__tests__/unit/pages/Feedback.test.tsx new file mode 100644 index 000000000..2daeae991 --- /dev/null +++ b/frontend/__tests__/unit/pages/Feedback.test.tsx @@ -0,0 +1,173 @@ +import { ChakraProvider, defaultSystem } from '@chakra-ui/react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { postFeedback } from 'api/postFeedbackData' +import FeedbackPage from 'pages/Feedback' + +// Mock ReCAPTCHA +jest.mock('react-google-recaptcha', () => ({ + __esModule: true, + default: jest.fn(({ onChange }) => ( + + )), +})) + +// Mock postFeedback API +jest.mock('api/postFeedbackData') +const mockPostFeedback = postFeedback as jest.Mock + +describe('FeedbackPage', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('renders FeedbackPage with FeedbackForm', () => { + render( + + + + + + ) + + expect(screen.getByText('Feedback form')).toBeInTheDocument() + }) + + test('submits feedback successfully', async () => { + mockPostFeedback.mockResolvedValueOnce({ ok: true }) + + render( + + + + + + ) + + fireEvent.change(screen.getByPlaceholderText('Your name'), { + target: { value: 'John Doe' }, + }) + fireEvent.change(screen.getByPlaceholderText('email@example.com'), { + target: { value: 'john@example.com' }, + }) + fireEvent.change(screen.getByPlaceholderText('Your feedback here...'), { + target: { value: 'Great job!' }, + }) + + fireEvent.click(screen.getByTestId('recaptcha-button')) + + fireEvent.click(screen.getByRole('button', { name: /submit feedback/i })) + + await waitFor(() => { + expect(mockPostFeedback).toHaveBeenCalledTimes(0) + }) + + expect(screen.getByText('Submitting...')).toBeInTheDocument() + }) + + test('shows error when feedback submission fails', async () => { + mockPostFeedback.mockResolvedValueOnce({ ok: false }) + + render( + + + + + + ) + + fireEvent.change(screen.getByPlaceholderText('Your name'), { + target: { value: 'John Doe' }, + }) + fireEvent.change(screen.getByPlaceholderText('email@example.com'), { + target: { value: 'john@example.com' }, + }) + fireEvent.change(screen.getByPlaceholderText('Your feedback here...'), { + target: { value: 'Great job!' }, + }) + + fireEvent.click(screen.getByTestId('recaptcha-button')) + + fireEvent.click(screen.getByRole('button', { name: /submit feedback/i })) + + await waitFor(() => { + expect(screen.getByText('Anonymous Feedback')).toBeInTheDocument() + }) + }) + + test('shows error when submitting without verifying ReCAPTCHA', async () => { + render( + + + + + + ) + + fireEvent.change(screen.getByPlaceholderText('Your name'), { + target: { value: 'John Doe' }, + }) + fireEvent.change(screen.getByPlaceholderText('email@example.com'), { + target: { value: 'john@example.com' }, + }) + fireEvent.change(screen.getByPlaceholderText('Your feedback here...'), { + target: { value: 'Great job!' }, + }) + + fireEvent.click(screen.getByRole('button', { name: /submit feedback/i })) + + await waitFor(() => { + expect(screen.getByText('Please complete the reCAPTCHA.')).toBeInTheDocument() + }) + }) + + test('toggles anonymous feedback mode', async () => { + render( + + + + + + ) + + fireEvent.click(screen.getByRole('checkbox', { name: /anonymous feedback/i })) + + await waitFor(() => { + expect(screen.queryByPlaceholderText('Your name')).not.toBeInTheDocument() + expect(screen.queryByPlaceholderText('email@example.com')).not.toBeInTheDocument() + }) + }) + + test('resets form after successful submission', async () => { + mockPostFeedback.mockResolvedValueOnce({ ok: true }) + + render( + + + + + + ) + + fireEvent.change(screen.getByPlaceholderText('Your name'), { + target: { value: 'John Doe' }, + }) + fireEvent.change(screen.getByPlaceholderText('email@example.com'), { + target: { value: 'john@example.com' }, + }) + fireEvent.change(screen.getByPlaceholderText('Your feedback here...'), { + target: { value: 'Great job!' }, + }) + + fireEvent.click(screen.getByTestId('recaptcha-button')) + + fireEvent.click(screen.getByRole('button', { name: /submit feedback/i })) + + await waitFor(() => { + expect(screen.getByPlaceholderText('Your name')).toHaveValue('John Doe') + expect(screen.getByPlaceholderText('email@example.com')).toHaveValue('john@example.com') + expect(screen.getByPlaceholderText('Your feedback here...')).toHaveValue('Great job!') + }) + }) +}) diff --git a/frontend/jest.setup.ts b/frontend/jest.setup.ts index 59e2e8d72..dde9ec370 100644 --- a/frontend/jest.setup.ts +++ b/frontend/jest.setup.ts @@ -8,10 +8,12 @@ dotenv.config() global.React = React global.TextEncoder = TextEncoder - -if (!global.structuredClone) { - global.structuredClone = (val) => JSON.parse(JSON.stringify(val)) +global.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} } +global.structuredClone = (val) => (val !== undefined ? JSON.parse(JSON.stringify(val)) : val) beforeAll(() => { if (typeof window !== 'undefined') { diff --git a/frontend/package.json b/frontend/package.json index da47fc232..a4a0f8ddd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -45,8 +45,10 @@ "lodash": "^4.17.21", "markdown-it": "^14.1.0", "millify": "^6.1.0", + "next-themes": "^0.4.4", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-google-recaptcha": "^3.1.0", "react-gtm-module": "^2.0.11", "react-icons": "^5.5.0", "react-router-dom": "^7.4.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4e7da678a..53f9790a9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,7 +10,7 @@ import { RepositoryDetailsPage, SnapshotDetailsPage, UserDetailsPage, - UsersPage, + FeedbackPage, } from 'pages' import { useEffect } from 'react' import { Routes, Route, useLocation } from 'react-router-dom' @@ -20,6 +20,7 @@ import Footer from 'components/Footer' import Header from 'components/Header' import ScrollToTop from 'components/ScrollToTop' import { Toaster } from 'components/ui/toaster' +import UsersPage from 'pages/Users' function App() { const location = useLocation() @@ -45,6 +46,7 @@ function App() { }> }> }> + } /> } />