From af410d42672b9d225ff009ccf617ca70b94f11b4 Mon Sep 17 00:00:00 2001 From: Matt Bertrand Date: Thu, 19 Feb 2026 10:05:26 -0500 Subject: [PATCH 01/16] Replace uwsgi with granian, remove heroku-specific files (#2956) --- Dockerfile | 2 +- Procfile | 5 - app.json | 749 ------------------------------------ config/nginx.conf.erb | 28 +- docker-compose.services.yml | 2 +- env/backend.env | 4 +- main/app_tests.py | 14 - poetry.lock | 147 +++++-- pyproject.toml | 4 +- scripts/run-django-dev.sh | 6 +- uwsgi.ini | 32 -- 11 files changed, 142 insertions(+), 851 deletions(-) delete mode 100644 Procfile delete mode 100644 app.json delete mode 100644 main/app_tests.py delete mode 100644 uwsgi.ini diff --git a/Dockerfile b/Dockerfile index 3f9343429f..170f7eea65 100644 --- a/Dockerfile +++ b/Dockerfile @@ -92,4 +92,4 @@ USER mitodl EXPOSE 8061 ENV PORT 8061 -CMD uwsgi uwsgi.ini +CMD ["sh", "-c", "exec granian --interface wsgi --host 0.0.0.0 --port 8061 --workers ${GRANIAN_WORKERS:-3} --blocking-threads ${GRANIAN_BLOCKING_THREADS:-2} main.wsgi:application"] diff --git a/Procfile b/Procfile deleted file mode 100644 index 0ec65d80d4..0000000000 --- a/Procfile +++ /dev/null @@ -1,5 +0,0 @@ -release: bash scripts/heroku-release-phase.sh -web: bin/start-nginx bin/start-pgbouncer uwsgi uwsgi.ini -worker: bin/start-pgbouncer celery -A main.celery:app worker -E -Q default --concurrency=2 -B -l $MITOL_LOG_LEVEL -extra_worker_2x: bin/start-pgbouncer celery -A main.celery:app worker -E -Q edx_content,default --concurrency=2 -l $MITOL_LOG_LEVEL -extra_worker_performance: bin/start-pgbouncer celery -A main.celery:app worker -E -Q edx_content,default -l $MITOL_LOG_LEVEL diff --git a/app.json b/app.json deleted file mode 100644 index 7333892ccd..0000000000 --- a/app.json +++ /dev/null @@ -1,749 +0,0 @@ -{ - "addons": ["heroku-postgresql:hobby-dev", "rediscloud:30"], - "buildpacks": [ - { - "url": "https://github.com/heroku/heroku-buildpack-apt" - }, - { - "url": "https://github.com/moneymeets/python-poetry-buildpack" - }, - { - "url": "https://github.com/heroku/heroku-buildpack-python" - }, - { - "url": "https://github.com/heroku/heroku-buildpack-pgbouncer" - }, - { - "url": "https://github.com/heroku/heroku-buildpack-nginx" - } - ], - "description": "mit-learn", - "env": { - "AI_CACHE_TIMEOUT": { - "description": "Timeout for AI cache", - "required": false - }, - "AI_DEBUG": { - "description": "Include debug information in AI responses if True", - "required": false - }, - "AI_MIT_SEARCH_URL": { - "description": "URL for AI search agent", - "required": false - }, - "AI_MODEL": { - "description": "Model to use for AI functionality", - "required": false - }, - "AI_MODEL_API": { - "description": "The API used for the AI model", - "required": false - }, - "AI_PROXY_CLASS": { - "description": "Proxy class for AI functionality", - "required": false - }, - "AI_PROXY_URL": { - "description": "URL for AI proxy", - "required": false - }, - "AI_PROXY_AUTH_TOKEN": { - "description": "Auth token for AI proxy", - "required": false - }, - "AI_MAX_PARALLEL_REQUESTS": { - "description": "Max parallel requests/user for AI functionality", - "required": false - }, - "AI_TPM_LIMIT": { - "description": "Tokens/minute limit per user for AI functionality", - "required": false - }, - "AI_RPM_LIMIT": { - "description": "Requests/minute limit per user for AI functionality", - "required": false - }, - "AI_BUDGET_DURATION": { - "description": "Length of time before a user's budget usage resets", - "required": false - }, - "AI_MAX_BUDGET": { - "description": "Max budget per user for AI functionality", - "required": false - }, - "AI_ANON_LIMIT_MULTIPLIER": { - "description": "Multiplier for per-user limit/budget shared by anonymous users", - "required": false - }, - "AI_MIT_SEARCH_LIMIT": { - "description": "Limit parameter value for AI search agent", - "required": false - }, - "ALLOWED_HOSTS": { - "description": "", - "required": false - }, - "AWS_ACCESS_KEY_ID": { - "description": "AWS Access Key for S3 storage.", - "required": false - }, - "AWS_SECRET_ACCESS_KEY": { - "description": "AWS Secret Key for S3 storage.", - "required": false - }, - "AWS_STORAGE_BUCKET_NAME": { - "description": "S3 Bucket name.", - "required": false - }, - "BLOCKLISTED_COURSES_URL": { - "description": "URL of a text file containing blocklisted course ids", - "required": false - }, - "DUPLICATE_COURSES_URL": { - "description": "URL of a text file containing course ids that are duplicates of each other", - "required": false - }, - "BOOTCAMPS_URL": { - "description": "URL to retrieve bootcamps data", - "required": false - }, - "CELERY_WORKER_MAX_MEMORY_PER_CHILD": { - "description": "Max memory to be used by celery worker child", - "required": false - }, - "CORS_ALLOWED_ORIGINS": { - "description": "A list of origins that are authorized to make cross-site HTTP requests", - "required": false - }, - "DRF_NESTED_PARENT_LOOKUP_PREFIX": { - "description": "DRF extensions parent lookup kwarg name prefix", - "required": false - }, - "CORS_ALLOWED_ORIGIN_REGEXES": { - "description": "A list of regexes that match origins that are authorized to make cross-site HTTP requests", - "required": false - }, - "COURSE_ARCHIVE_BUCKET_NAME": { - "description": "Name of the shared S3 bucket that stores course archive tarballs", - "required": false - }, - "CSAIL_BASE_URL": { - "description": "CSAIL courses base URL", - "required": false - }, - "CSRF_COOKIE_DOMAIN": { - "description": "The domain to set the CSRF cookie on", - "required": false - }, - "DEFAULT_SEARCH_MODE": { - "description": "Default search mode for the search API and frontend", - "required": false - }, - "DEFAULT_SEARCH_SLOP": { - "description": "Default slop value for the search API and frontend. Only used for phrase queries.", - "required": false - }, - "DEFAULT_SEARCH_STALENESS_PENALTY": { - "description": "Default staleness penalty value for the search API and frontend", - "required": false - }, - "DEFAULT_SEARCH_MINIMUM_SCORE_CUTOFF": { - "description": "Default minimum score cutoff value for the search API and frontend", - "required": false - }, - "DEFAULT_SEARCH_MAX_INCOMPLETENESS_PENALTY": { - "description": "Default max incompleteness penalty value for the search API and frontend", - "required": false - }, - "DEFAULT_SEARCH_CONTENT_FILE_SCORE_WEIGHT": { - "description": "Default score weight for content file search match", - "required": false - }, - "EDX_API_ACCESS_TOKEN_URL": { - "description": "URL to retrieve a MITx access token", - "required": false - }, - "EDX_API_URL": { - "description": "URL to retrieve MITx course data from", - "required": false - }, - "EDX_API_CLIENT_ID": { - "description": "EdX client id to access the MITx course catalog API", - "required": false - }, - "EDX_API_CLIENT_SECRET": { - "description": "EdX secret key to access the MITx course catalog API", - "required": false - }, - "EDX_ALT_URL": { - "description": "Base alternate URL for MITx courses hosted on edX", - "required": false - }, - "EDX_BASE_URL": { - "description": "Base default URL for MITx courses hosted on edX", - "required": false - }, - "EDX_COURSE_BUCKET_PREFIX": { - "description": "S3 prefix within the shared archive bucket for MITx on edX exports", - "required": false - }, - "EDX_LEARNING_COURSE_BUCKET_PREFIX": { - "description": "S3 prefix for MITx bucket keys", - "required": false - }, - "EDX_PROGRAMS_API_URL": { - "description": "The catalog url for MITx programs", - "required": false - }, - "OPENSEARCH_HTTP_AUTH": { - "description": "Basic auth settings for connecting to OpenSearch" - }, - "OPENSEARCH_CONNECTIONS_PER_NODE": { - "description": "The size of the connection pool created for each node detected within an OpenSearch cluster.", - "required": false - }, - "OPENSEARCH_DEFAULT_TIMEOUT": { - "description": "The default timeout in seconds for OpenSearch requests", - "required": false - }, - "OPENSEARCH_INDEX": { - "description": "Index to use on OpenSearch", - "required": true - }, - "OPENSEARCH_MAX_REQUEST_SIZE": { - "description": "Maximum size of JSON data requests sent to OpenSearch", - "required": false - }, - "OPENSEARCH_SHARD_COUNT": { - "description": "Number of shards to allocate when creating an OpenSearch index. Generally set to the CPU count of an individual node in the cluster.", - "required": false - }, - "OPENSEARCH_REPLICA_COUNT": { - "description": "Number of index replicas to create when initializing a new OpenSearch index. Generally set to the number of search nodes available in the cluster.", - "required": false - }, - "OPENSEARCH_INDEXING_CHUNK_SIZE": { - "description": "Chunk size to use for OpenSearch indexing tasks", - "required": false - }, - "OPENSEARCH_DOCUMENT_INDEXING_CHUNK_SIZE": { - "description": "Chunk size to use for OpenSearch course document indexing", - "required": false - }, - "OPENSEARCH_MAX_SUGGEST_HITS": { - "description": "Return suggested search terms only if the number of hits is equal to or below this value", - "required": false - }, - "OPENSEARCH_MAX_SUGGEST_RESULTS": { - "description": "The maximum number of search term suggestions to return", - "required": false - }, - "OPENSEARCH_MIN_QUERY_SIZE": { - "description": "Minimimum number of characters in a query string to search for", - "required": false - }, - "OPENSEARCH_URL": { - "description": "URL for connecting to OpenSearch cluster" - }, - "GA_TRACKING_ID": { - "description": "Google analytics tracking ID", - "required": false - }, - "GA_G_TRACKING_ID": { - "description": "Google analytics GTM tracking ID", - "required": false - }, - "GITHUB_ACCESS_TOKEN": { - "description": "Access token for the Github API", - "required": false - }, - "IMAGEKIT_CACHEFILE_DIR": { - "description": "Prefix path for cached images generated by imagekit", - "required": false - }, - "INDEXING_ERROR_RETRIES": { - "description": "Number of times to retry an indexing operation on failure", - "required": false - }, - "LEARNING_COURSE_ITERATOR_CHUNK_SIZE": { - "description": "Chunk size for iterating over xPRO/MITx Online courses for master json", - "required": false - }, - "MAILGUN_URL": { - "description": "The URL for communicating with Mailgun" - }, - "MAILGUN_KEY": { - "description": "The token for authenticating against the Mailgun API" - }, - "MAILGUN_FROM_EMAIL": { - "description": "Email which mail comes from" - }, - "MAILGUN_BCC_TO_EMAIL": { - "description": "Email address used with bcc email", - "required": false - }, - "MAILGUN_SENDER_DOMAIN": { - "description": "Domain used for emails sent via mailgun" - }, - "MAX_S3_GET_ITERATIONS": { - "description": "Max retry attempts to get an S3 object", - "required": false - }, - "MICROMASTERS_CATALOG_API_URL": { - "description": "URL to MicroMasters catalog API", - "required": "false" - }, - "MITPE_BASE_URL": { - "description": "Base URL for MIT Professional Education website", - "required": "false" - }, - "MITX_ONLINE_BASE_URL": { - "description": "Base default URL for MITx Online courses", - "required": false - }, - "MITX_ONLINE_PROGRAMS_API_URL": { - "description": "The catalog url for MITx Online programs", - "required": false - }, - "MITX_ONLINE_COURSES_API_URL": { - "description": "The api url for MITx Online courses", - "required": false - }, - "MITX_ONLINE_COURSE_BUCKET_PREFIX": { - "description": "S3 prefix within the shared archive bucket for MITx Online exports", - "required": false - }, - "MITX_ONLINE_LEARNING_COURSE_BUCKET_NAME": { - "description": "Name of S3 bucket to upload MITx Online course media", - "required": false - }, - "MIT_WS_CERTIFICATE": { - "description": "X509 certificate as a string", - "required": false - }, - "MIT_WS_PRIVATE_KEY": { - "description": "X509 private key as a string", - "required": false - }, - "OCW_BASE_URL": { - "description": "Base URL for OCW courses", - "required": false - }, - "OCW_CONTENT_BUCKET_NAME": { - "description": "Name of S3 bucket containing OCW course data", - "required": false - }, - "OCW_ITERATOR_CHUNK_SIZE": { - "description": "Chunk size for iterating over OCW courses for master json", - "required": false - }, - "OCW_LEARNING_COURSE_BUCKET_NAME": { - "description": "Name of S3 bucket to upload OCW course media", - "required": false - }, - "OCW_UPLOAD_IMAGE_ONLY": { - "description": "Upload course image only instead of all OCW files", - "required": false - }, - "OCW_SKIP_CONTENT_FILES": { - "description": "Skip upserting of OCW content files", - "required": false - }, - "OCW_WEBHOOK_DELAY": { - "description": "Delay in seconds to process an OCW course after receiving webhook", - "required": false - }, - "OCW_WEBHOOK_KEY": { - "description": "Authentication parameter value that should be passed in a webhook", - "required": false - }, - "OLL_LEARNING_COURSE_BUCKET_NAME": { - "description": "Name of S3 bucket containing OLL content files", - "required": false - }, - "OLL_COURSE_BUCKET_PREFIX": { - "description": "S3 prefix within the shared archive bucket for OLL exports", - "required": false - }, - "OLL_LEARNING_COURSE_BUCKET_PREFIX": { - "description": "Prefix to use with the OLL S3 bucket", - "required": false - }, - "MITOL_ADMIN_EMAIL": { - "description": "E-mail to send 500 reports to.", - "required": false - }, - "MITOL_AUTHENTICATION_PLUGINS": { - "description": "List of pluggy plugins to use for authentication", - "required": false - }, - "MITOL_LEARNING_RESOURCES_PLUGINS": { - "description": "List of pluggy plugins to use for learning resources", - "required": false - }, - "MITOL_APP_BASE_URL": { - "description": "Base url to create links to the app", - "required": false - }, - "MITOL_COOKIE_NAME": { - "description": "Name of the cookie for the JWT auth token", - "required": false - }, - "MITOL_COOKIE_DOMAIN": { - "description": "Domain for the cookie for the JWT auth token", - "required": false - }, - "MITOL_DB_CONN_MAX_AGE": { - "value": "0", - "required": false - }, - "MITOL_DB_DISABLE_SSL": { - "value": "True", - "required": false - }, - "MITOL_DB_DISABLE_SS_CURSORS": { - "description": "Disable server-side cursors", - "required": false - }, - "MITOL_EMAIL_HOST": { - "description": "Outgoing e-mail settings", - "required": false - }, - "MITOL_EMAIL_PASSWORD": { - "description": "Outgoing e-mail settings", - "required": false - }, - "MITOL_EMAIL_PORT": { - "description": "Outgoing e-mail settings", - "value": "587", - "required": false - }, - "MITOL_EMAIL_TLS": { - "description": "Outgoing e-mail settings", - "value": "True", - "required": false - }, - "MITOL_EMAIL_USER": { - "description": "Outgoing e-mail settings", - "required": false - }, - "MITOL_ENVIRONMENT": { - "description": "The execution environment that the app is in (e.g. dev, staging, prod)", - "required": false - }, - "MITOL_FROM_EMAIL": { - "description": "E-mail to use for the from field", - "required": false - }, - "MITOL_LOG_LEVEL": { - "description": "The log level for the application", - "required": false, - "value": "INFO" - }, - "MITOL_NEW_USER_LOGIN_URL": { - "description": "Url to redirect new users to", - "required": false - }, - "MITOL_JWT_SECRET": { - "description": "Shared secret for JWT auth tokens", - "required": false - }, - "MITOL_NOINDEX": { - "description": "Prevent search engines from indexing the site", - "required": false - }, - "MITOL_SECURE_SSL_REDIRECT": { - "description": "Application-level SSL redirect setting.", - "value": "True", - "required": false - }, - "MITOL_SIMILAR_RESOURCES_COUNT": { - "description": "Number of similar resources to return", - "required": false - }, - "MITOL_TITLE": { - "description": "Title of the MIT Learn site", - "required": false - }, - "MITOL_SUPPORT_EMAIL": { - "description": "Email address listed for customer support", - "required": false - }, - "MITOL_UNSUBSCRIBE_TOKEN_MAX_AGE_SECONDS": { - "description": "Maximum age of unsubscribe tokens in seconds", - "required": false - }, - "MITOL_USE_S3": { - "description": "Use S3 for storage backend (required on Heroku)", - "value": "False", - "required": false - }, - "NEWS_EVENTS_MEDIUM_NEWS_SCHEDULE_SECONDS": { - "description": "Time in seconds between periodic syncs of Medium MIT News feed", - "required": false - }, - "NEWS_EVENTS_SLOAN_NEWS_SCHEDULE_SECONDS": { - "description": "Time in seconds between periodic syncs of Sloan news", - "required": false - }, - "NEWS_EVENTS_OL_EVENTS_SCHEDULE_SECONDS": { - "description": "Time in seconds between periodic syncs of Open Learning events", - "required": false - }, - "OPEN_PODCAST_DATA_BRANCH": { - "description": "Branch in the open podcast data repository to use for podcast ingestion", - "required": false - }, - "OPEN_RESOURCES_MIN_DOC_FREQ": { - "description": "OpenSearch min_doc_freq value for determining similar resources", - "required": false - }, - "OPEN_RESOURCES_MIN_TERM_FREQ": { - "description": "OpenSearch min_term_freq value for determining similar resources", - "required": false - }, - "OPEN_VIDEO_DATA_BRANCH": { - "description": "Branch in the open video data repository to use for video downloads", - "required": false - }, - "OPEN_VIDEO_MAX_TOPICS": { - "description": "Maximum number of topics to assign a video", - "required": false - }, - "OPEN_VIDEO_MIN_DOC_FREQ": { - "description": "OpenSearch min_doc_freq value for determing video topics", - "required": false - }, - "OPEN_VIDEO_MIN_TERM_FREQ": { - "description": "OpenSearch min_term_freq value for determing video topics", - "required": false - }, - "OPEN_VIDEO_USER_LIST_OWNER": { - "description": "User who will own user lists generated from playlists", - "required": false - }, - "NEW_RELIC_APP_NAME": { - "description": "Application identifier in New Relic." - }, - "NODE_MODULES_CACHE": { - "description": "If false, disables the node_modules cache to fix yarn install", - "value": "false" - }, - "OCW_NEXT_LIVE_BUCKET": { - "description": "bucket for ocw-next courses data", - "required": false - }, - "OCW_NEXT_AWS_STORAGE_BUCKET_NAME": { - "description": "bucket for ocw-next storage data", - "required": false - }, - "OCW_OFFLINE_DELIVERY": { - "description": "Enable offline delivery of OCW courses", - "required": false - }, - "PGBOUNCER_DEFAULT_POOL_SIZE": { - "value": "50" - }, - "PGBOUNCER_MIN_POOL_SIZE": { - "value": "5" - }, - "PODCAST_FETCH_SCHEDULE_SECONDS": { - "description": "The time in seconds between periodic syncs of podcasts", - "required": false - }, - "RECAPTCHA_SITE_KEY": { - "description": "Google Recaptcha site key", - "required": false - }, - "RECAPTCHA_SECRET_KEY": { - "description": "Google Recaptcha secret key", - "required": false - }, - "REQUESTS_TIMEOUT": { - "description": "Default timeout for requests", - "required": false - }, - "RSS_FEED_EPISODE_LIMIT": { - "description": "Number of episodes included in aggregated rss feed", - "required": false - }, - "RSS_FEED_CACHE_MINUTES": { - "description": "Minutes that /podcasts/rss_feed will be cached", - "required": false - }, - "SECRET_KEY": { - "description": "Django secret key.", - "generator": "secret" - }, - "SEE_API_ACCESS_TOKEN_URL": { - "description": "URL to retrieve a MITx access token", - "required": false - }, - "SEE_API_URL": { - "description": "URL to retrieve MITx course data from", - "required": false - }, - "SEE_API_CLIENT_ID": { - "description": "EdX client id to access the MITx course catalog API", - "required": false - }, - "SEE_API_CLIENT_SECRET": { - "description": "EdX secret key to access the MITx course catalog API", - "required": false - }, - "SENTRY_DSN": { - "description": "The connection settings for Sentry" - }, - "SENTRY_LOG_LEVEL": { - "description": "The log level for Sentry", - "required": false - }, - "STATUS_TOKEN": { - "description": "Token to access the status API." - }, - "TIKA_ACCESS_TOKEN": { - "description": "X-Access-Token value for tika requests", - "required": false - }, - "TIKA_OCR_STRATEGY": { - "description": "OCR strategy to specify in header for tika requests", - "required": false - }, - "TIKA_TIMEOUT": { - "description": "Timeout for tika requests", - "required": false - }, - "USE_X_FORWARDED_PORT": { - "description": "Use the X-Forwarded-Port", - "required": false - }, - "USE_X_FORWARDED_HOST": { - "description": "Use the X-Forwarded-Host", - "required": false - }, - "CKEDITOR_ENVIRONMENT_ID": { - "description": "env ID for CKEditor EasyImage auth", - "required": false - }, - "CKEDITOR_SECRET_KEY": { - "description": "secret key for CKEditor EasyImage auth", - "required": false - }, - "CKEDITOR_UPLOAD_URL": { - "description": "upload URL for CKEditor EasyImage", - "required": false - }, - "XPRO_CATALOG_API_URL": { - "description": "The catalog url for xpro programs", - "required": false - }, - "XPRO_COURSES_API_URL": { - "description": "The api url for xpro courses", - "required": false - }, - "XPRO_COURSE_BUCKET_PREFIX": { - "description": "S3 prefix within the shared archive bucket for xPRO exports", - "required": false - }, - "XPRO_LEARNING_COURSE_BUCKET_NAME": { - "description": "Name of S3 bucket to upload xPRO course media", - "required": false - }, - "YOUTUBE_DEVELOPER_KEY": { - "description": "The key to the google youtube api", - "required": false - }, - "YOUTUBE_FETCH_SCHEDULE_SECONDS": { - "description": "The time in seconds between periodic syncs of youtube videos", - "required": false - }, - "YOUTUBE_FETCH_TRANSCRIPT_SCHEDULE_SECONDS": { - "description": "The time in seconds between periodic syncs of youtube video transcripts", - "required": false - }, - "SOCIAL_AUTH_OL_OIDC_OIDC_ENDPOINT": { - "description": "The base URI for OpenID Connect discovery, https:/// without .well-known/openid-configuration.", - "required": false - }, - "SOCIAL_AUTH_OL_OIDC_KEY": { - "description": "The client ID provided by the OpenID Connect provider.", - "required": false - }, - "SOCIAL_AUTH_OL_OIDC_SECRET": { - "description": "The client secret provided by the OpenID Connect provider.", - "required": false - }, - "SOCIAL_AUTH_ALLOWED_REDIRECT_HOSTS": { - "description": "The list of additional redirect hosts allowed for social auth.", - "required": false - }, - "USERINFO_URL": { - "description": "Provder endpoint where client sends requests for identity claims.", - "required": false - }, - "ACCESS_TOKEN_URL": { - "description": "Provider endpoint where client exchanges the authorization code for tokens.", - "required": false - }, - "AUTHORIZATION_URL": { - "description": "Provider endpoint where the user is asked to authenticate.", - "required": false - }, - "FEATURE_KEYCLOAK_ENABLED": { - "description": "Authentication functionality is managed by Keycloak.", - "required": true - }, - "KEYCLOAK_REALM_NAME": { - "description": "The Keycloak realm name in which Open Discussions has a client configuration.", - "required": true - }, - "KEYCLOAK_BASE_URL": { - "description": "The base URL for a Keycloak configuration.", - "required": true - }, - "POSTHOG_API_HOST": { - "description": "API host for PostHog", - "required": false - }, - "POSTHOG_PROJECT_API_KEY": { - "description": "Project API key to capture events in PostHog", - "required": false - }, - "POSTHOG_PERSONAL_API_KEY": { - "description": "Private API key to communicate with PostHog", - "required": false - }, - "POSTHOG_PROJECT_ID": { - "description": "PostHog project ID for the application", - "required": false - }, - "POSTHOG_EVENT_S3_PREFIX": { - "description": "S3 prefix for PostHog event uploads", - "required": false - }, - "POSTHOG_EVENT_S3_FOLDER": { - "description": " S3 bucket where PostHog event files are stored", - "required": false - }, - "POSTHOG_TIMEOUT_MS": { - "description": "Timeout for communication with PostHog API", - "required": false - }, - "CANVAS_COURSE_BUCKET_PREFIX": { - "description": "S3 prefix within the shared archive bucket for Canvas exports", - "required": false - }, - "CANVAS_TUTORBOT_FOLDER": { - "description": "Folder in Canvas course zip files where tutorbot problem and solution files are stored", - "required": false - } - }, - "keywords": ["Django", "Python", "MIT", "Office of Digital Learning"], - "name": "mit_open", - "repository": "https://github.com/mitodl/mit-learn", - "scripts": { - "postdeploy": "./manage.py migrate --noinput" - }, - "success_url": "/", - "website": "https://github.com/mitodl/mit-learn" -} diff --git a/config/nginx.conf.erb b/config/nginx.conf.erb index 3d811f923c..97da4686ec 100644 --- a/config/nginx.conf.erb +++ b/config/nginx.conf.erb @@ -48,26 +48,14 @@ http { } location / { - uwsgi_param QUERY_STRING $query_string; - uwsgi_param REQUEST_METHOD $request_method; - uwsgi_param CONTENT_TYPE $content_type; - uwsgi_param CONTENT_LENGTH $content_length; - uwsgi_param REQUEST_URI $request_uri; - uwsgi_param PATH_INFO $document_uri; - uwsgi_param DOCUMENT_ROOT $document_root; - uwsgi_param SERVER_PROTOCOL $server_protocol; - uwsgi_param REMOTE_ADDR $remote_addr; - uwsgi_param REMOTE_PORT $remote_port; - uwsgi_param SERVER_ADDR $server_addr; - uwsgi_param SERVER_PORT $server_port; - uwsgi_param SERVER_NAME $server_name; - uwsgi_param X-Forwarded-For $proxy_add_x_forwarded_for; - uwsgi_param X-Forwarded-Proto $http_x_forwarded_proto; - uwsgi_param X-Forwarded-Port $http_x_forwarded_port; - uwsgi_param X-Forwarded-Host $http_x_forwarded_host; - uwsgi_pass <%= ENV["NGINX_UWSGI_PASS"] || "unix:/tmp/nginx.socket" %>; - uwsgi_pass_request_headers on; - uwsgi_pass_request_body on; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + proxy_set_header X-Forwarded-Port $http_x_forwarded_port; + proxy_set_header X-Forwarded-Host $http_x_forwarded_host; + proxy_pass http://<%= ENV["NGINX_BACKEND"] || "127.0.0.1:8061" %>; + proxy_redirect off; client_max_body_size 25M; } } diff --git a/docker-compose.services.yml b/docker-compose.services.yml index d9b2ca3ad0..1776852d82 100644 --- a/docker-compose.services.yml +++ b/docker-compose.services.yml @@ -60,7 +60,7 @@ services: - web environment: PORT: 8063 - NGINX_UWSGI_PASS: "web:8061" + NGINX_BACKEND: "web:8061" volumes: - ./config:/etc/nginx/templates diff --git a/env/backend.env b/env/backend.env index 9e83ce1115..8a4ba4e51b 100644 --- a/env/backend.env +++ b/env/backend.env @@ -34,8 +34,8 @@ OPENSEARCH_INDEXING_CHUNK_SIZE=100 PORT=8061 -UWSGI_WORKERS=3 -UWSGI_THREADS=35 +GRANIAN_WORKERS=3 +GRANIAN_BLOCKING_THREADS=4 TIKA_SERVER_ENDPOINT=http://tika:9998/ TIKA_CLIENT_ONLY=True diff --git a/main/app_tests.py b/main/app_tests.py deleted file mode 100644 index 8240e20125..0000000000 --- a/main/app_tests.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Application level tests""" - -import json - - -def test_app_json_valid(): - """Verify app.json is parsable and has some necessary keys""" - with open("app.json") as f: # noqa: PTH123 - config = json.load(f) - - assert isinstance(config, dict) # noqa: S101 - - for required_key in ["addons", "buildpacks", "env"]: - assert required_key in config # noqa: S101 diff --git a/poetry.lock b/poetry.lock index 90b4fdd754..36cd8ac2d6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2824,6 +2824,7 @@ files = [ [package.dependencies] click = ">=8.0.0" +watchfiles = {version = ">=1.0,<2.0", optional = true, markers = "extra == \"reload\""} [package.extras] all = ["granian[dotenv,pname,reload]"] @@ -8904,28 +8905,6 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] -[[package]] -name = "uwsgi" -version = "2.0.29" -description = "The uWSGI server" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "uwsgi-2.0.29.tar.gz", hash = "sha256:6bd150ae60d0d9947429ea7dc8e5f868de027e5eb38355fb613b9413732c432f"}, -] - -[[package]] -name = "uwsgitop" -version = "0.12" -description = "uWSGI top-like interface" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "uwsgitop-0.12.tar.gz", hash = "sha256:4f9330951f0fb9633226de36cf0c28c04dcf323efab608834aa81f638b6019b2"}, -] - [[package]] name = "vine" version = "5.1.0" @@ -8938,6 +8917,128 @@ files = [ {file = "vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0"}, ] +[[package]] +name = "watchfiles" +version = "1.1.1" +description = "Simple, modern and high performance file watching and code reload in python." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c"}, + {file = "watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43"}, + {file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31"}, + {file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac"}, + {file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d"}, + {file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d"}, + {file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863"}, + {file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab"}, + {file = "watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82"}, + {file = "watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4"}, + {file = "watchfiles-1.1.1-cp310-cp310-win32.whl", hash = "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844"}, + {file = "watchfiles-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e"}, + {file = "watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5"}, + {file = "watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741"}, + {file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6"}, + {file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b"}, + {file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14"}, + {file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d"}, + {file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff"}, + {file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606"}, + {file = "watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701"}, + {file = "watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10"}, + {file = "watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849"}, + {file = "watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4"}, + {file = "watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e"}, + {file = "watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d"}, + {file = "watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610"}, + {file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af"}, + {file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6"}, + {file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce"}, + {file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa"}, + {file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb"}, + {file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803"}, + {file = "watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94"}, + {file = "watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43"}, + {file = "watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9"}, + {file = "watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9"}, + {file = "watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404"}, + {file = "watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18"}, + {file = "watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a"}, + {file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219"}, + {file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428"}, + {file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0"}, + {file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150"}, + {file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae"}, + {file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d"}, + {file = "watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b"}, + {file = "watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374"}, + {file = "watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0"}, + {file = "watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42"}, + {file = "watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18"}, + {file = "watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da"}, + {file = "watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051"}, + {file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e"}, + {file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70"}, + {file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261"}, + {file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620"}, + {file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04"}, + {file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77"}, + {file = "watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef"}, + {file = "watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf"}, + {file = "watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5"}, + {file = "watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd"}, + {file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb"}, + {file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5"}, + {file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3"}, + {file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33"}, + {file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510"}, + {file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05"}, + {file = "watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6"}, + {file = "watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81"}, + {file = "watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b"}, + {file = "watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a"}, + {file = "watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02"}, + {file = "watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21"}, + {file = "watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5"}, + {file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7"}, + {file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101"}, + {file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44"}, + {file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c"}, + {file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc"}, + {file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c"}, + {file = "watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099"}, + {file = "watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01"}, + {file = "watchfiles-1.1.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c882d69f6903ef6092bedfb7be973d9319940d56b8427ab9187d1ecd73438a70"}, + {file = "watchfiles-1.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d6ff426a7cb54f310d51bfe83fe9f2bbe40d540c741dc974ebc30e6aa238f52e"}, + {file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79ff6c6eadf2e3fc0d7786331362e6ef1e51125892c75f1004bd6b52155fb956"}, + {file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c1f5210f1b8fc91ead1283c6fd89f70e76fb07283ec738056cf34d51e9c1d62c"}, + {file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9c4702f29ca48e023ffd9b7ff6b822acdf47cb1ff44cb490a3f1d5ec8987e9c"}, + {file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acb08650863767cbc58bca4813b92df4d6c648459dcaa3d4155681962b2aa2d3"}, + {file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08af70fd77eee58549cd69c25055dc344f918d992ff626068242259f98d598a2"}, + {file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c3631058c37e4a0ec440bf583bc53cdbd13e5661bb6f465bc1d88ee9a0a4d02"}, + {file = "watchfiles-1.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:cf57a27fb986c6243d2ee78392c503826056ffe0287e8794503b10fb51b881be"}, + {file = "watchfiles-1.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d7e7067c98040d646982daa1f37a33d3544138ea155536c2e0e63e07ff8a7e0f"}, + {file = "watchfiles-1.1.1-cp39-cp39-win32.whl", hash = "sha256:6c9c9262f454d1c4d8aaa7050121eb4f3aea197360553699520767daebf2180b"}, + {file = "watchfiles-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:74472234c8370669850e1c312490f6026d132ca2d396abfad8830b4f1c096957"}, + {file = "watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3"}, + {file = "watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2"}, + {file = "watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d"}, + {file = "watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b"}, + {file = "watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88"}, + {file = "watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336"}, + {file = "watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24"}, + {file = "watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49"}, + {file = "watchfiles-1.1.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cdab464fee731e0884c35ae3588514a9bcf718d0e2c82169c1c4a85cc19c3c7f"}, + {file = "watchfiles-1.1.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:3dbd8cbadd46984f802f6d479b7e3afa86c42d13e8f0f322d669d79722c8ec34"}, + {file = "watchfiles-1.1.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5524298e3827105b61951a29c3512deb9578586abf3a7c5da4a8069df247cccc"}, + {file = "watchfiles-1.1.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b943d3668d61cfa528eb949577479d3b077fd25fb83c641235437bc0b5bc60e"}, + {file = "watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2"}, +] + +[package.dependencies] +anyio = ">=3.0.0" + [[package]] name = "wcwidth" version = "0.2.13" @@ -9488,4 +9589,4 @@ cffi = ["cffi (>=1.11)"] [metadata] lock-version = "2.1" python-versions = "~3.12" -content-hash = "aa40d98372564b86b8f2a559a5244da8e2c4bc24108d755335d5297ea74045b9" +content-hash = "670f9cead78fc9d0e6ff784e960729b03522538ae5dce69feef2aeddb742e4dc" diff --git a/pyproject.toml b/pyproject.toml index bda0e7e187..7e02d4cc8a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,7 @@ drf-nested-routers = "^0.95.0" drf-spectacular = "^0.28.0" feedparser = "^6.0.10" google-api-python-client = "^2.89.0" -granian = "^2.5.4" +granian = {extras = ["reload"], version = "^2.5.4"} html2text = "^2025.0.0" html5lib = "^1.1" ipython = "^9.0.0" @@ -109,8 +109,6 @@ tldextract = "^5.0.0" toolz = "^1.0.0" ulid-py = "^1.0.0" urllib3 = "^2.0.0" -uwsgi = "^2.0.29" -uwsgitop = "^0.12" wrapt = "^1.14.1" youtube-transcript-api = "^1.0.0" pypdfium2 = "^5.0.0" diff --git a/scripts/run-django-dev.sh b/scripts/run-django-dev.sh index 2b2a7021e0..3dd68a690d 100755 --- a/scripts/run-django-dev.sh +++ b/scripts/run-django-dev.sh @@ -7,6 +7,10 @@ python3 manage.py collectstatic --noinput --clear # run initial django migrations python3 manage.py migrate --noinput +granian --interface wsgi --host 0.0.0.0 --port "${PORT:-8061}" --workers "${GRANIAN_WORKERS:-3}" --blocking-threads "${GRANIAN_BLOCKING_THREADS:-2}" --reload --reload-ignore-dirs frontends --reload-ignore-dirs staticfiles --reload-ignore-dirs .git main.wsgi:application & +GRANIAN_PID=$! +echo "Application started with PID $GRANIAN_PID" + # populate cache table python3 manage.py createcachetable @@ -23,4 +27,4 @@ python manage.py create_qdrant_collections # consolidate user subscriptions and remove duplicate percolate instances python manage.py prune_subscription_queries 2>&1 -uwsgi uwsgi.ini --honour-stdin +wait $GRANIAN_PID diff --git a/uwsgi.ini b/uwsgi.ini deleted file mode 100644 index 823e716296..0000000000 --- a/uwsgi.ini +++ /dev/null @@ -1,32 +0,0 @@ -[uwsgi] -strict = true -if-env = DEV_ENV -socket = :$(PORT) -endif = -if-not-env = DEV_ENV -socket = /tmp/nginx.socket -endif = -hook-accepting1 = exec:touch /tmp/app-initialized -master = true -die-on-term = true -wsgi-file = main/wsgi.py -pidfile=/tmp/mit_open-mast.pid -vacuum=True -enable-threads = true -single-interpreter = true -offload-threads = 2 -thunder-lock = -if-env = DEV_ENV -python-autoreload = 1 -endif = -if-not-env = DEV_ENV -memory-report = true -endif = -if-not-env = UWSGI_SOCKET_TIMEOUT -socket-timeout = 3 -endif = -buffer-size = 65535 -stats = /tmp/uwsgi-stats.sock -reload-on-rss = 400 -# for sentry -py-call-uwsgi-fork-hooks = true From e5720d067d89a091902996c85d50c4f1a54942f4 Mon Sep 17 00:00:00 2001 From: Ahtesham Quraish Date: Fri, 20 Feb 2026 12:27:57 +0500 Subject: [PATCH 02/16] fix: improvements in editor exprience (#2954) * fix: improvements in editor exprience --------- Co-authored-by: Ahtesham Quraish --- .../ArticleEditor.happydom.test.tsx | 42 ------------------- .../TiptapEditor/TiptapEditor.tsx | 4 +- .../LearningResourceInputNode.tsx | 2 +- .../node/MediaEmbed/MediaEmbedInputNode.tsx | 2 +- .../TiptapEditor/tiptap-commands.d.ts | 11 +++++ .../TiptapEditor/useArticleSchema.ts | 2 - 6 files changed, 14 insertions(+), 49 deletions(-) create mode 100644 frontends/main/src/page-components/TiptapEditor/tiptap-commands.d.ts diff --git a/frontends/main/src/page-components/TiptapEditor/ArticleEditor.happydom.test.tsx b/frontends/main/src/page-components/TiptapEditor/ArticleEditor.happydom.test.tsx index f958d7132d..f9d42ea1df 100644 --- a/frontends/main/src/page-components/TiptapEditor/ArticleEditor.happydom.test.tsx +++ b/frontends/main/src/page-components/TiptapEditor/ArticleEditor.happydom.test.tsx @@ -902,48 +902,6 @@ describe("ArticleEditor - Document Rendering", () => { expect(italicText.closest("em") || italicText.closest("i")).toBeTruthy() }) - test("renders document with highlighted text", async () => { - const content: JSONContent = { - type: "doc", - content: [ - { - type: "banner", - content: [ - { - type: "heading", - attrs: { level: 1 }, - content: [{ type: "text", text: "Title" }], - }, - { - type: "paragraph", - content: [], - }, - ], - }, - { - type: "byline", - }, - { - type: "paragraph", - content: [ - { type: "text", text: "This is " }, - { - type: "text", - marks: [{ type: "highlight" }], - text: "highlighted text", - }, - { type: "text", text: " in a paragraph." }, - ], - }, - ], - } - - await setupEditor(content) - - const highlightedText = await screen.findByText("highlighted text") - expect(highlightedText.closest("mark")).toBeTruthy() - }) - test("renders document with links", async () => { const content: JSONContent = { type: "doc", diff --git a/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx b/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx index 0539f63e76..9e8ef9aa6c 100644 --- a/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx +++ b/frontends/main/src/page-components/TiptapEditor/TiptapEditor.tsx @@ -29,7 +29,6 @@ import { HeadingDropdownMenu } from "./vendor/components/tiptap-ui/heading-dropd import { ListDropdownMenu } from "./vendor/components/tiptap-ui/list-dropdown-menu" import { BlockquoteButton } from "./vendor/components/tiptap-ui/blockquote-button" import { CodeBlockButton } from "./vendor/components/tiptap-ui/code-block-button" -import { ColorHighlightPopover } from "./vendor/components/tiptap-ui/color-highlight-popover" import { LinkPopover } from "./vendor/components/tiptap-ui/link-popover" import { MarkButton } from "./vendor/components/tiptap-ui/mark-button" import { TextAlignButton } from "./vendor/components/tiptap-ui/text-align-button" @@ -220,7 +219,7 @@ export const MainToolbarContent = ({ editor }: TiptapEditorToolbarProps) => { - + @@ -234,7 +233,6 @@ export const MainToolbarContent = ({ editor }: TiptapEditorToolbarProps) => { - diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/node/LearningResource/LearningResourceInputNode.tsx b/frontends/main/src/page-components/TiptapEditor/extensions/node/LearningResource/LearningResourceInputNode.tsx index 85f31f9fb3..8e7e8da94e 100644 --- a/frontends/main/src/page-components/TiptapEditor/extensions/node/LearningResource/LearningResourceInputNode.tsx +++ b/frontends/main/src/page-components/TiptapEditor/extensions/node/LearningResource/LearningResourceInputNode.tsx @@ -14,7 +14,7 @@ const learningResourceInputConfig: ExtendedNodeConfig = { }, renderHTML({ HTMLAttributes }: { HTMLAttributes: Record }) { - return ["learning-resource-input", mergeAttributes(HTMLAttributes), 0] + return ["p", mergeAttributes(HTMLAttributes), 0] }, getPlaceholders: (childNode: ProseMirrorNode) => { diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/node/MediaEmbed/MediaEmbedInputNode.tsx b/frontends/main/src/page-components/TiptapEditor/extensions/node/MediaEmbed/MediaEmbedInputNode.tsx index 6b911cc335..579e7fa50d 100644 --- a/frontends/main/src/page-components/TiptapEditor/extensions/node/MediaEmbed/MediaEmbedInputNode.tsx +++ b/frontends/main/src/page-components/TiptapEditor/extensions/node/MediaEmbed/MediaEmbedInputNode.tsx @@ -14,7 +14,7 @@ const mediaEmbedInputConfig: ExtendedNodeConfig = { }, renderHTML({ HTMLAttributes }: { HTMLAttributes: Record }) { - return ["media-embed-input", mergeAttributes(HTMLAttributes), 0] + return ["p", mergeAttributes(HTMLAttributes), 0] }, getPlaceholders: (childNode: ProseMirrorNode) => { diff --git a/frontends/main/src/page-components/TiptapEditor/tiptap-commands.d.ts b/frontends/main/src/page-components/TiptapEditor/tiptap-commands.d.ts new file mode 100644 index 0000000000..4df18dff31 --- /dev/null +++ b/frontends/main/src/page-components/TiptapEditor/tiptap-commands.d.ts @@ -0,0 +1,11 @@ +import "@tiptap/core" + +declare module "@tiptap/core" { + interface Commands { + highlight: { + setHighlight: (attributes?: { color: string }) => ReturnType + toggleHighlight: (attributes?: { color: string }) => ReturnType + unsetHighlight: () => ReturnType + } + } +} diff --git a/frontends/main/src/page-components/TiptapEditor/useArticleSchema.ts b/frontends/main/src/page-components/TiptapEditor/useArticleSchema.ts index 460f702c70..5e192b7265 100644 --- a/frontends/main/src/page-components/TiptapEditor/useArticleSchema.ts +++ b/frontends/main/src/page-components/TiptapEditor/useArticleSchema.ts @@ -12,7 +12,6 @@ import { Heading } from "@tiptap/extension-heading" import { Image } from "@tiptap/extension-image" import { TextAlign } from "@tiptap/extension-text-align" import { Typography as TiptapTypography } from "@tiptap/extension-typography" -import { Highlight } from "@tiptap/extension-highlight" import { Subscript } from "@tiptap/extension-subscript" import { Superscript } from "@tiptap/extension-superscript" import type { JSONContent } from "@tiptap/react" @@ -147,7 +146,6 @@ export const useArticleSchema = ({ TextAlign.configure({ types: ["heading", "paragraph"] }), TaskList, TaskItem.configure({ nested: true }), - Highlight.configure({ multicolor: true }), TiptapTypography, Superscript, Subscript, From 0219986382173d29362b5060ba0328af8f761822 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 10:35:04 -0500 Subject: [PATCH 03/16] fix(deps): update dependency cffi to v2 (#2759) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- poetry.lock | 166 +++++++++++++++++++++++++++---------------------- pyproject.toml | 2 +- 2 files changed, 93 insertions(+), 75 deletions(-) diff --git a/poetry.lock b/poetry.lock index 36cd8ac2d6..2726795b4c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -755,83 +755,100 @@ files = [ [[package]] name = "cffi" -version = "1.17.1" +version = "2.0.0" description = "Foreign Function Interface for Python calling C code." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main", "dev"] 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"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, - {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, - {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, - {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, - {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, - {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, - {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, - {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, - {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, - {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, - {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, - {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, - {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, - {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, - {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, - {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, - {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, - {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, - {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, - {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, - {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, - {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, - {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, - {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, - {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, - {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, - {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, - {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, - {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, - {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, -] - -[package.dependencies] -pycparser = "*" + {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, + {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, + {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, + {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, + {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, + {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, + {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, + {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, + {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, + {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, + {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, + {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, + {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, + {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, + {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, + {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, + {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, + {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, + {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, + {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, + {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} [[package]] name = "charset-normalizer" @@ -6432,6 +6449,7 @@ description = "C parser in Python" optional = false python-versions = ">=3.8" groups = ["main", "dev"] +markers = "implementation_name != \"PyPy\"" files = [ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, @@ -9589,4 +9607,4 @@ cffi = ["cffi (>=1.11)"] [metadata] lock-version = "2.1" python-versions = "~3.12" -content-hash = "670f9cead78fc9d0e6ff784e960729b03522538ae5dce69feef2aeddb742e4dc" +content-hash = "b858620408a928094d2d1ac60d41001a430867f9367a5d472368062352ff2fdd" diff --git a/pyproject.toml b/pyproject.toml index 7e02d4cc8a..5a4b1858ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ boto3 = "^1.26.155" cairosvg = "2.8.2" celery = "^5.3.1" celery-redbeat = "^2.3.2" -cffi = "^1.15.1" +cffi = "^2.0.0" configargparse = ">=1.7.1" cryptography = "^45.0.0" dateparser = "^1.2.0" From cd390ceca9655b0dcd4ab770552450430e7b0d90 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 10:44:50 -0500 Subject: [PATCH 04/16] fix(deps): update dependency django-imagekit to v6 (#2762) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 2726795b4c..0637928595 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1637,14 +1637,14 @@ resolved_reference = "53f9bdc3a7acc8a577319987fef0bd3040eef4b4" [[package]] name = "django-imagekit" -version = "5.0.0" +version = "6.0.0" description = "Automated image processing for Django models." optional = false python-versions = "*" groups = ["main"] files = [ - {file = "django-imagekit-5.0.0.tar.gz", hash = "sha256:aae9f74a8e9b6ceb5d15f7d8e266302901e76d9f532c78bd5135cb0fa206a6b0"}, - {file = "django_imagekit-5.0.0-py3-none-any.whl", hash = "sha256:a8e77ed6549751026a51f961bb2cd5fda739be691496da8eecbe68ffb966c261"}, + {file = "django_imagekit-6.0.0-py3-none-any.whl", hash = "sha256:dd75c7eec22684a085652033d4f5b869d80c8e7295aa737edbaa04a680e564b6"}, + {file = "django_imagekit-6.0.0.tar.gz", hash = "sha256:66a28fa87b52d2bec382cf3405aa94535dae25b2cadaceab9e8ec3f253b7410c"}, ] [package.dependencies] @@ -9607,4 +9607,4 @@ cffi = ["cffi (>=1.11)"] [metadata] lock-version = "2.1" python-versions = "~3.12" -content-hash = "b858620408a928094d2d1ac60d41001a430867f9367a5d472368062352ff2fdd" +content-hash = "a97362d9df3c9f3e497a19a368ad14df6754182a39e34e44f25be819b69ded23" diff --git a/pyproject.toml b/pyproject.toml index 5a4b1858ce..688a520d25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ django-guardian = "^3.0.0" django-health-check = { git = "https://github.com/revsys/django-health-check", rev="53f9bdc3a7acc8a577319987fef0bd3040eef4b4" } # pragma: allowlist secret -django-imagekit = "^5.0.0" +django-imagekit = "^6.0.0" django-ipware = "^7.0.0" django-json-widget = "^2.0.0" django-oauth-toolkit = "^3.0.0" From 34236b17de3d9c52fd3103231d806c651923bb6e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 10:59:36 -0500 Subject: [PATCH 05/16] fix(deps): update dependency cryptography to v46 [security] (#2946) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- poetry.lock | 112 +++++++++++++++++++++++++++---------------------- pyproject.toml | 2 +- 2 files changed, 63 insertions(+), 51 deletions(-) diff --git a/poetry.lock b/poetry.lock index 0637928595..64ccd280ea 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1173,62 +1173,74 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cryptography" -version = "45.0.3" +version = "46.0.5" 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" +python-versions = "!=3.9.0,!=3.9.1,>=3.8" groups = ["main", "dev"] files = [ - {file = "cryptography-45.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:7573d9eebaeceeb55285205dbbb8753ac1e962af3d9640791d12b36864065e71"}, - {file = "cryptography-45.0.3-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d377dde61c5d67eb4311eace661c3efda46c62113ff56bf05e2d679e02aebb5b"}, - {file = "cryptography-45.0.3-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fae1e637f527750811588e4582988932c222f8251f7b7ea93739acb624e1487f"}, - {file = "cryptography-45.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ca932e11218bcc9ef812aa497cdf669484870ecbcf2d99b765d6c27a86000942"}, - {file = "cryptography-45.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af3f92b1dc25621f5fad065288a44ac790c5798e986a34d393ab27d2b27fcff9"}, - {file = "cryptography-45.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2f8f8f0b73b885ddd7f3d8c2b2234a7d3ba49002b0223f58cfde1bedd9563c56"}, - {file = "cryptography-45.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9cc80ce69032ffa528b5e16d217fa4d8d4bb7d6ba8659c1b4d74a1b0f4235fca"}, - {file = "cryptography-45.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c824c9281cb628015bfc3c59335163d4ca0540d49de4582d6c2637312907e4b1"}, - {file = "cryptography-45.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5833bb4355cb377ebd880457663a972cd044e7f49585aee39245c0d592904578"}, - {file = "cryptography-45.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bb5bf55dcb69f7067d80354d0a348368da907345a2c448b0babc4215ccd3497"}, - {file = "cryptography-45.0.3-cp311-abi3-win32.whl", hash = "sha256:3ad69eeb92a9de9421e1f6685e85a10fbcfb75c833b42cc9bc2ba9fb00da4710"}, - {file = "cryptography-45.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:97787952246a77d77934d41b62fb1b6f3581d83f71b44796a4158d93b8f5c490"}, - {file = "cryptography-45.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:c92519d242703b675ccefd0f0562eb45e74d438e001f8ab52d628e885751fb06"}, - {file = "cryptography-45.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5edcb90da1843df85292ef3a313513766a78fbbb83f584a5a58fb001a5a9d57"}, - {file = "cryptography-45.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38deed72285c7ed699864f964a3f4cf11ab3fb38e8d39cfcd96710cd2b5bb716"}, - {file = "cryptography-45.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5555365a50efe1f486eed6ac7062c33b97ccef409f5970a0b6f205a7cfab59c8"}, - {file = "cryptography-45.0.3-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e4253ed8f5948a3589b3caee7ad9a5bf218ffd16869c516535325fece163dcc"}, - {file = "cryptography-45.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cfd84777b4b6684955ce86156cfb5e08d75e80dc2585e10d69e47f014f0a5342"}, - {file = "cryptography-45.0.3-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:a2b56de3417fd5f48773ad8e91abaa700b678dc7fe1e0c757e1ae340779acf7b"}, - {file = "cryptography-45.0.3-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:57a6500d459e8035e813bd8b51b671977fb149a8c95ed814989da682314d0782"}, - {file = "cryptography-45.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f22af3c78abfbc7cbcdf2c55d23c3e022e1a462ee2481011d518c7fb9c9f3d65"}, - {file = "cryptography-45.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:232954730c362638544758a8160c4ee1b832dc011d2c41a306ad8f7cccc5bb0b"}, - {file = "cryptography-45.0.3-cp37-abi3-win32.whl", hash = "sha256:cb6ab89421bc90e0422aca911c69044c2912fc3debb19bb3c1bfe28ee3dff6ab"}, - {file = "cryptography-45.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:d54ae41e6bd70ea23707843021c778f151ca258081586f0cfa31d936ae43d1b2"}, - {file = "cryptography-45.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ed43d396f42028c1f47b5fec012e9e12631266e3825e95c00e3cf94d472dac49"}, - {file = "cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fed5aaca1750e46db870874c9c273cd5182a9e9deb16f06f7bdffdb5c2bde4b9"}, - {file = "cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:00094838ecc7c6594171e8c8a9166124c1197b074cfca23645cee573910d76bc"}, - {file = "cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:92d5f428c1a0439b2040435a1d6bc1b26ebf0af88b093c3628913dd464d13fa1"}, - {file = "cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:ec64ee375b5aaa354b2b273c921144a660a511f9df8785e6d1c942967106438e"}, - {file = "cryptography-45.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:71320fbefd05454ef2d457c481ba9a5b0e540f3753354fff6f780927c25d19b0"}, - {file = "cryptography-45.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:edd6d51869beb7f0d472e902ef231a9b7689508e83880ea16ca3311a00bf5ce7"}, - {file = "cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:555e5e2d3a53b4fabeca32835878b2818b3f23966a4efb0d566689777c5a12c8"}, - {file = "cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:25286aacb947286620a31f78f2ed1a32cded7be5d8b729ba3fb2c988457639e4"}, - {file = "cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:050ce5209d5072472971e6efbfc8ec5a8f9a841de5a4db0ebd9c2e392cb81972"}, - {file = "cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:dc10ec1e9f21f33420cc05214989544727e776286c1c16697178978327b95c9c"}, - {file = "cryptography-45.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:9eda14f049d7f09c2e8fb411dda17dd6b16a3c76a1de5e249188a32aeb92de19"}, - {file = "cryptography-45.0.3.tar.gz", hash = "sha256:ec21313dd335c51d7877baf2972569f40a4291b76a0ce51391523ae358d05899"}, -] - -[package.dependencies] -cffi = {version = ">=1.14", markers = "platform_python_implementation != \"PyPy\""} - -[package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs ; python_full_version >= \"3.8.0\"", "sphinx-rtd-theme (>=3.0.0) ; python_full_version >= \"3.8.0\""] + {file = "cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0"}, + {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731"}, + {file = "cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82"}, + {file = "cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1"}, + {file = "cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48"}, + {file = "cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4"}, + {file = "cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0"}, + {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663"}, + {file = "cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826"}, + {file = "cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d"}, + {file = "cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a"}, + {file = "cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4"}, + {file = "cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d"}, + {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c"}, + {file = "cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4"}, + {file = "cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9"}, + {file = "cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72"}, + {file = "cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595"}, + {file = "cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c"}, + {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a"}, + {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356"}, + {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da"}, + {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257"}, + {file = "cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7"}, + {file = "cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] -nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_full_version >= \"3.8.0\""] -pep8test = ["check-sdist ; python_full_version >= \"3.8.0\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] +nox = ["nox[uv] (>=2024.4.15)"] +pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"] sdist = ["build (>=1.0.0)"] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi (>=2024)", "cryptography-vectors (==45.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test = ["certifi (>=2024)", "cryptography-vectors (==46.0.5)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] test-randomorder = ["pytest-randomly"] [[package]] @@ -9607,4 +9619,4 @@ cffi = ["cffi (>=1.11)"] [metadata] lock-version = "2.1" python-versions = "~3.12" -content-hash = "a97362d9df3c9f3e497a19a368ad14df6754182a39e34e44f25be819b69ded23" +content-hash = "15e0a131057156db806e5194be14ae05f1a015300cc3b22f8b6d4935fe951a01" diff --git a/pyproject.toml b/pyproject.toml index 688a520d25..d7afcd8601 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ celery = "^5.3.1" celery-redbeat = "^2.3.2" cffi = "^2.0.0" configargparse = ">=1.7.1" -cryptography = "^45.0.0" +cryptography = "^46.0.0" dateparser = "^1.2.0" deepmerge = "^2.0" dj-database-url = "^3.0.0" From f1320dd468a3eff429ce22d349dd28692db14e83 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Fri, 20 Feb 2026 12:44:37 -0500 Subject: [PATCH 06/16] add CLAUDE.md pointing to AGENTS.md (#2966) --- .gitignore | 2 ++ CLAUDE.md | 3 +++ 2 files changed, 5 insertions(+) create mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index 94e3057fe2..91ae956bd7 100644 --- a/.gitignore +++ b/.gitignore @@ -143,3 +143,5 @@ backups/ /test-results/ /playwright-report/ /playwright/.cache/ + +.claude diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..2065fd2504 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +# Claude Instructions + +@AGENTS.md From ed7a27c11a5013b2be402474a39c6e6014b0d5a9 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Fri, 20 Feb 2026 13:39:50 -0500 Subject: [PATCH 07/16] Product page style updates (#2962) * sticky nav, scroll spy, and overflow handling * add update what, how, who section styling * use icon for dialog trigger * good catch, sentry * fix a test * better info icon positioning, earn a cert wording * allow scrolling nav links, space-between * move enrollmentbutton logic into template * add mobile view * style updates, Earn a Certificate linkification * update base don claude review * fix small issue * one more claude review * fix some spacing * update smoot * remove sticky navbar * revert smoot bump * priority-load the above-the-fold image * update yarn.lock * unionify productNoun * better icon alignment * remove a debug * make how learn data required --- .../public/images/product/icon_book_play.png | Bin 0 -> 3533 bytes .../public/images/product/icon_brains.png | Bin 0 -> 8540 bytes .../images/product/icon_certificate.png | Bin 0 -> 3866 bytes .../product/icon_computer_lightbulb.png | Bin 0 -> 2650 bytes .../images/product/icon_connected_people.png | Bin 0 -> 8288 bytes .../ProductPages/AboutSection.test.tsx | 6 +- .../app-pages/ProductPages/AboutSection.tsx | 3 +- .../ProductPages/CourseEnrollmentButton.tsx | 1 + .../ProductPages/CoursePage.test.tsx | 22 +- .../src/app-pages/ProductPages/CoursePage.tsx | 79 +- .../ProductPages/HowYoullLearnSection.tsx | 142 ++ .../app-pages/ProductPages/ProductNavbar.tsx | 69 + .../ProductPages/ProductPageTemplate.tsx | 225 +- .../ProductPages/ProductSummary.test.tsx | 2208 ++++++++--------- .../app-pages/ProductPages/ProductSummary.tsx | 363 +-- .../ProgramEnrollmentButton.test.tsx | 2 +- .../ProductPages/ProgramPage.test.tsx | 23 +- .../app-pages/ProductPages/ProgramPage.tsx | 94 +- .../src/app-pages/ProductPages/RawHTML.tsx | 16 +- .../ProductPages/WhatYoullLearnSection.tsx | 64 + .../ProductPages/WhoCanTakeSection.tsx | 42 + .../main/src/app-pages/ProductPages/util.ts | 5 + 22 files changed, 1818 insertions(+), 1546 deletions(-) create mode 100644 frontends/main/public/images/product/icon_book_play.png create mode 100644 frontends/main/public/images/product/icon_brains.png create mode 100644 frontends/main/public/images/product/icon_certificate.png create mode 100644 frontends/main/public/images/product/icon_computer_lightbulb.png create mode 100644 frontends/main/public/images/product/icon_connected_people.png create mode 100644 frontends/main/src/app-pages/ProductPages/HowYoullLearnSection.tsx create mode 100644 frontends/main/src/app-pages/ProductPages/ProductNavbar.tsx create mode 100644 frontends/main/src/app-pages/ProductPages/WhatYoullLearnSection.tsx create mode 100644 frontends/main/src/app-pages/ProductPages/WhoCanTakeSection.tsx diff --git a/frontends/main/public/images/product/icon_book_play.png b/frontends/main/public/images/product/icon_book_play.png new file mode 100644 index 0000000000000000000000000000000000000000..1c34690db9f3bcb5addb29729d05a4bbcf60be08 GIT binary patch literal 3533 zcmV;;4KnhHP)0kumL62ng5_il~~+=KBFuz5Yf4G(Y#qi)t2dQNx@p%UPc!?3nRy z6bh?%rM0pvmC9wBRy)$Di_?X|>X9^)uT(02)ah>(MaiLFuXezA)ByZ9#b)Osb9JP- zE-XmJyneS@twJ&gMIEw#F_o2Et+sdCrKQExlCD%LcJ#YqalSiqY$jq~-|AEo&`iWa zAw6|*;>3w_e-8}}p8H#tWmhGfpFeLnq+oi&V(JN)HbpcO5I%?N%mfTX3Dcs8UI>^L zMO;C^He@mxTP2*GoenNK4uXi~auK>tAY!3bB4dCoXOPe511DgoL|j^0YQ#5s=yf4U z3RbOJ<>~vockjl&ef!$tG%vrkB4A+V*sp13jDay0d5^ACDyUQ{ffLY=XqOtckFpEd@ob-m{4`&>zwL;~`Il-MVqot<_2N?VWhO28%whXC9M zz)L7M81VaD2jEuToxuVh0eA$!e2m(98-Q;h{3MYQfTsXV26aJQuRY;0%s&a>QKeq5 zjsf_5&@P(?6;v6c!vM=i;Vv>(TL<9Vwq2XD0pHOzTj^!SaCsE$XRN*hz~}7#zgTpg zmZR3|;U2H|US2N$PXTZVj~Bb}!jjup>ZeYjjEK_17^7micXahJi^e!%8tS6u-XltD z@24jLOxgbcmji6n%f+XrfI(;DT_%#RVocvj#6mU4l0>t8eHXms97rq$d{5U`yh+#V zyTb02L{wso4!Q+uetH-Y_5kE0{nsSzPs+=zNfu)WpLIusil!SWSQ^)CMdMTW!bZ*WV2Vc83-U*+Yrg6VVGfBcj#2G0H zg+j;AI505KqFxgd?>T;-I|&B|fw{SOlhI7RLZODGr3{z*3=FJCsZ@$J84HC%ONa5; zR^-#|GAk#GWI}UpZuL1rMom5|^_!TO2<;j2`Ml@kTHfR{6R?R~?lSK8=Bk9oP>;R4 z;5C~!sit>@hvmsk#}UiRu2C)-!<+`TZQIsjj>pH}a!^8I$xZDD!6_Fo;3119o=O<@ zx{;VuI#qAGckkYo#vdDd)>a9Lz!X6cXEoGyT~tIv4cDw$<0a6DM;8?;m|hiZM!epm zV_hhrP($ySpPz@Ss;)|C-i;xWZYoqT^(yEKx-NV|p@u$DEEcVX*2ua3+O^7Doq>|j zMg>;^C?w3EnM2nT=awp%0dPUWc((w6W99|_jOKf%rr1+JQBG2=4-a25k;&w)1TYie zWv`!^nf@JsL@hy_m{^0ctEjR_;+q%3C+c0&S-s`Hai*foCBC<4G=01Wp=M63G>Ox#E-1>0*#P_2P_ zJ+(tszF%An(RedSq7qDXVNoCClb0=lj1v4qQqK)o+bs6^Prbgp2xI${ckR?rQG|66 zO2`jINm8!Sb^TgdRvZ@6_s-%bha61uX^#0SVXvy84URR6fKWn#2vx9E;=HL(OJ+4U z5~fHEQvp?V9HgeRXJe{_vYdsc)e!Bev^rFE4T{BuL^i%8DUC(*(bOT4u>#SL{`gVC zRApAHYQGUpB!r9~eDEOvVAG~ekv~g5e}1W|J*hh1U5*Rlzutu?Li|JC9vtR!2w1B$7{6RV*wlxG5p?V4?Xc zVUkqPiHx0`-yxK*GaphYp$*LIC_HYDW{LOTKiO0D(%;{2r-n`cuTA#c)|dpE%9V5# zw12P8`f8XPD}^dujZ7L->39&6LfAEbJ&dzLqRgzGfsx_8*ewzLC}MyF{1kw%0?0;G zf0F>722k@#QQvhNfI6V-N-+V&q zxfzqSroZ3pVe(yL@xI(KHa6x@tD{oF7^oot-~l#$Z6i8{EZc8~^AmPakN91&(L%q^ zCgzR>yy4)%U&7(U5AvDs;eN`eh?E7w0{47EP1CSt%T}k1+^#S)Gb2{?0obwQN*p=z z7Gzn$x^?RUz4pmJnI3|1$9Q)R{|)f+MG*l{KId+{4V2MJFA*aRaxD3xRk8p0>+ z3}*m*H;rUutm>$Q&5c~mMUvVI&ny~C6;!}yeMAkO*FA72y}$SFvEXKH9i9HHh}4d zI-(J*4Fr6jIGl&;Qy7SeAsW7r8-d9Q056ybcnrWz0A5SxFinty)f}Q@8#PQL`Mw6= z#{jmPK0gLvAAsMB{&}9EBC}eM&zAz)*;I3&;l&1XRYF4zjpvXg3ZY_*9p(l3c-ONA z9usVwGOJk&8Ha`jxl~H?x)dz~BPU;T1soADHufw>0yg8GPKIbgRl?My+Nap((~-@# zb$nXxcn0PqPIbHQSq&qTybCPzTE!@#7Ay=aK= zNyhN&7}SFX;I|KA!=E_c=e`(oVGE2DGBpx2QD5%rzKS!^sNK9b3_N3O1AyI8*S;}r zgx!$P-x71_9#uNwvpbD+w5J)v4N2!=9=x>24VZB~od4|Wkih?UNjXo2X79@fO$ZxcFAUPF2S>rwL1@OAAGd1GJ zSyI*hS5|&lEY7zzn@K!7@(JI8=TvWjkWc|);h*z~jxE$M5qJ;-j|*-_4+|AsY02D` zFqhK-7;`e3mW}&6oA+=2PD&Ioo()ko$d}Cm7E^lO=Z*~mo`)5Hd0ea){9tjFu$dviAfjYa zLcJxv?+F45B!P&QylNjOOez8QOB)CGOB+A-5N$})j+y!W(#FB$5z%3di6pU$0SqRw zHTLMIr;KQepu)R3{^{yqPIxbu&xY>_*Yyfxn93!u&CVT{+Ll?Z>+Eo~ns{|M3gD`9 zA${-S(*XVmKuunowFb5djNJix28^*QN~O|f0RIg0zMki*gTqe7p!PtMl@$Q{iRf_v zt9#&uG=N^6oSb|&>>Cdc4~Kor7m2i60ot5g!NA+HEWh%9XCT=4?kP#w00000NkvXX Hu0mjfgusuq literal 0 HcmV?d00001 diff --git a/frontends/main/public/images/product/icon_brains.png b/frontends/main/public/images/product/icon_brains.png new file mode 100644 index 0000000000000000000000000000000000000000..5cc8002cd2975da5789d56ec105a5a90695e9343 GIT binary patch literal 8540 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 literal 0 HcmV?d00001 diff --git a/frontends/main/public/images/product/icon_computer_lightbulb.png b/frontends/main/public/images/product/icon_computer_lightbulb.png new file mode 100644 index 0000000000000000000000000000000000000000..9f328716bc1be6730c94f9b39aa530bea06e33eb GIT binary patch literal 2650 zcmV-g3Z?alP)SlZ7*%H+FBoI1Y400$C0Z1ArGsJ)mmbG0G-m(LP{ZRNn32vN1pfi$DZVFLYv$qclVyN`}@w^ z*|T?d&)GfayTARO-+3v5#G*x6o;h$mx_A|^1elE=%IF1lq0}F#tGR=g~Lx1n>$e!&qa8Wa^y^o!;ioEhjSfqSD&@ zj*!f(rJ$Eop0}$lww@Tt6m%p>*8!T$O}5~3ZcLS z1kQtm%7)2KZ4?$bZGOuwzX-~PF^{KiM6p*0VY|gu^U=lcRMv>OC>#7p zrvA*}sg8=}vDQMg32ntRjbSFUj6pk|Ar(Nb=n!7IilM#G9JDMe2AM!>XNME-ii?^z z&aSez0Y`w_V_cg{sP=)cAFg~@Fw6(Cj9r-&W;n7YhEX1gO!@MwX@@< z5D2_$+%^c}{sUMgvLP66gS7~Oz`q9G3ak#0xL34xcJw%q-69(tB=8R4WKhHfY$mSA zh7b^jgoqn)BjIgu1>*^PpGEifZTE?*MK**$;BT4mYc08_Wxx%N5;sP}fb76m@O-Dw zTXH|0z+=D*z{f=@1WjH?ybTjzyG8fX3tSulo{4M_xFvMO+aM5xMqc-2gCMq=S7bvY z@H(Igm;M$lx_i{{qNGD4WDpW+Cn}pM6XP?9IR@@LIvjMGx>*;5EQs&3_*T9*Gu# z-Ifhe!;N^&v;cp?n~&`A8N~Mja5?Z(v%doPiO2>kWJ9DZ@B7TB9^h}lmB33Ch<6ZJ z2i$G;TPgJ-a~UEVEI|hNp~RR5Y;M1l;2JlJX#Q@S6 zFy4oLwlJWLQFSHsjsz+OsSIQbsfij;dvpZJMS#_APAJa$z+r)ZznKp!t+B+*Pqri5#W!= zhAQARu*Zbo5@NbF1kc%vi2z(>vY5xkD< z_MlkwS0K-gmTX9+rt-!$lrm-bJWI*@Ghc=6>JZX6PgMZ;&nQ2z#F&L3Ve;|^koolgQeJx*HQex zX1|T8JM7ov`HoKl@5Y-=M^IJrE?pXzUQqiejcH3V^4&jOg<@*q=9^^YaR-fRtoG#c+Y~Kuk67-MCRjn9TEmB{R5wiHYNw$Oiko4X|@(lx4%effiu# zG_T)n;t*JAd83ag$NC z4jo-bB0G4->vgd-v-Z&7R#dtuMPfMywf}2fPcn1u8TnBxE-tABeG$pbM=Hdxej4f@ z;_%^<3YFdptdLR>I3%H;rr5t&q0-WET;LwE$%UzLb;2Lau-525o}{#r&UCI1;CLe7 z_;r<^FXG3w{s7>Uw<=vkrM2iyf&FSBv)6D{lgkwnl!k6A+S+7ML4n;4hEXD`AyDb# zqik+!I!E$HV6o-x8s%zDO=AL7X;F;cULmi*V(1b-^ZC;W{IeAH_s@|G5?F~sAxCL0 zxRZ2upGW`)Mo$=Kf#j0FN*p}sTaQ^C07#(tr_U8}ndFkdN(>b*EpeCsnEG3O`KSn1 z=|eO&)<{kX?5nJ$w~q2OnKlb8wzVZtnI}gNBr@%^w8*@@0(+3ie%@zbd#J7TE%lqs ztnwYysPs0Hnbj;>l$D$lR1KNTQWBZF$8a-|*;rB!qwLOXKXi z>H}P~Yz;eh42~tQXP&P^7r%kGHkd$QO?3^$oh+FD1@`Xkf4SuyL4Ex!j-R{^t*-!< z13Ae%L6vH0`%wB}QtEpQ3_NuS_njy5pTKwRpQ*q zo^3kUFhQ@eAbV3)Gwv|X9YGP)ODKI5rH+tNd+6)iKhXvM2LauSBR%VUlK=n!07*qo IM6N<$f{fx9bpQYW literal 0 HcmV?d00001 diff --git a/frontends/main/public/images/product/icon_connected_people.png b/frontends/main/public/images/product/icon_connected_people.png new file mode 100644 index 0000000000000000000000000000000000000000..edd75592ed1ce29d1e7cd6dc57f4d1a486603d2a GIT binary patch literal 8288 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 eCu{YzDf~a*3yVR!&l>vx0000 { test("About section has expected content", async () => { const about = `

${faker.lorem.paragraph()}

` - const noun = faker.word.noun() + const noun = faker.helpers.arrayElement(["Course", "Program"] as const) renderWithProviders() const section = await screen.findByRole("region", { @@ -31,7 +31,7 @@ test("About section expands and collapses", async () => { const aboutContent = [firstParagraph, secondParagraph, thirdParagraph] .map((p) => `

${p}

`) .join("\n") - const noun = faker.word.noun() + const noun = faker.helpers.arrayElement(["Course", "Program"] as const) const page = makePage({ about: aboutContent }) invariant(page.about) @@ -71,7 +71,7 @@ test.each([ { length: aboutParagraphs }, (_, i) => `

This is paragraph ${i + 1} in the about section.

`, ).join("\n") - const noun = faker.word.noun() + const noun = faker.helpers.arrayElement(["Course", "Program"] as const) renderWithProviders( , diff --git a/frontends/main/src/app-pages/ProductPages/AboutSection.tsx b/frontends/main/src/app-pages/ProductPages/AboutSection.tsx index a6778ede6c..9b52152319 100644 --- a/frontends/main/src/app-pages/ProductPages/AboutSection.tsx +++ b/frontends/main/src/app-pages/ProductPages/AboutSection.tsx @@ -4,6 +4,7 @@ import { HeadingIds } from "./util" import RawHTML from "./RawHTML" import { Typography } from "ol-components" import { styled } from "@mitodl/smoot-design" +import type { ProductNoun } from "./util" const AboutSectionRoot = styled.section<{ expanded: boolean }>( ({ expanded }) => { @@ -33,7 +34,7 @@ const AboutSectionRoot = styled.section<{ expanded: boolean }>( const AboutSection: React.FC<{ aboutHtml: string - productNoun: string + productNoun: ProductNoun }> = ({ aboutHtml, productNoun }) => { const [aboutExpanded, setAboutExpanded] = React.useState(false) return ( diff --git a/frontends/main/src/app-pages/ProductPages/CourseEnrollmentButton.tsx b/frontends/main/src/app-pages/ProductPages/CourseEnrollmentButton.tsx index 664a3706c3..4ab2afc633 100644 --- a/frontends/main/src/app-pages/ProductPages/CourseEnrollmentButton.tsx +++ b/frontends/main/src/app-pages/ProductPages/CourseEnrollmentButton.tsx @@ -50,6 +50,7 @@ const CourseEnrollmentButton: React.FC = ({ onClick={handleClick} variant="primary" size="large" + data-testid="course-enrollment-button" > {getButtonText(nextRun)} diff --git a/frontends/main/src/app-pages/ProductPages/CoursePage.test.tsx b/frontends/main/src/app-pages/ProductPages/CoursePage.test.tsx index 172c1c3443..7336b59c4d 100644 --- a/frontends/main/src/app-pages/ProductPages/CoursePage.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/CoursePage.test.tsx @@ -99,6 +99,7 @@ describe("CoursePage", () => { { level: 2, name: "Course summary" }, { level: 2, name: "About this Course" }, { level: 2, name: "What you'll learn" }, + { level: 2, name: "How you'll learn" }, { level: 2, name: "Prerequisites" }, { level: 2, name: "Meet your instructors" }, { level: 2, name: "Who can take this Course?" }, @@ -123,11 +124,14 @@ describe("CoursePage", () => { expect(links[1]).toHaveTextContent("What you'll learn") expect(links[1]).toHaveAttribute("href", `#${HeadingIds.What}`) expect(document.getElementById(HeadingIds.What)).toBeVisible() - expect(links[2]).toHaveTextContent("Prerequisites") - expect(links[2]).toHaveAttribute("href", `#${HeadingIds.Prereqs}`) + expect(links[2]).toHaveTextContent("How you'll learn") + expect(links[2]).toHaveAttribute("href", `#${HeadingIds.How}`) + expect(document.getElementById(HeadingIds.How)).toBeVisible() + expect(links[3]).toHaveTextContent("Prerequisites") + expect(links[3]).toHaveAttribute("href", `#${HeadingIds.Prereqs}`) expect(document.getElementById(HeadingIds.Prereqs)).toBeVisible() - expect(links[3]).toHaveTextContent("Instructors") - expect(links[3]).toHaveAttribute("href", `#${HeadingIds.Instructors}`) + expect(links[4]).toHaveTextContent("Instructors") + expect(links[4]).toHaveAttribute("href", `#${HeadingIds.Instructors}`) expect(document.getElementById(HeadingIds.Instructors)).toBeVisible() }) @@ -184,6 +188,16 @@ describe("CoursePage", () => { expectRawContent(section, page.prerequisites) }) + test("Renders an enrollment button", async () => { + const course = makeCourse() + const page = makePage({ course_details: course }) + setupApis({ course, page }) + renderWithProviders() + + const buttons = await screen.findAllByTestId("course-enrollment-button") + expect(buttons.length).toBeGreaterThanOrEqual(1) + }) + test.each([ { courses: [], pages: [makePage()] }, { courses: [makeCourse()], pages: [] }, diff --git a/frontends/main/src/app-pages/ProductPages/CoursePage.tsx b/frontends/main/src/app-pages/ProductPages/CoursePage.tsx index 820bb48d4c..3249fb6244 100644 --- a/frontends/main/src/app-pages/ProductPages/CoursePage.tsx +++ b/frontends/main/src/app-pages/ProductPages/CoursePage.tsx @@ -1,7 +1,7 @@ "use client" import React from "react" -import { Stack, Typography } from "ol-components" +import { Typography } from "ol-components" import { pagesQueries } from "api/mitxonline-hooks/pages" import { useQuery } from "@tanstack/react-query" @@ -15,11 +15,11 @@ import { HeadingIds } from "./util" import InstructorsSection from "./InstructorsSection" import RawHTML from "./RawHTML" import AboutSection from "./AboutSection" -import ProductPageTemplate, { - HeadingData, - ProductNavbar, - WhoCanTake, -} from "./ProductPageTemplate" +import ProductPageTemplate from "./ProductPageTemplate" +import ProductNavbar, { HeadingData } from "./ProductNavbar" +import WhoCanTakeSection from "./WhoCanTakeSection" +import WhatYoullLearnSection from "./WhatYoullLearnSection" +import HowYoullLearnSection, { DEFAULT_HOW_DATA } from "./HowYoullLearnSection" import { CoursePageItem } from "@mitodl/mitxonline-api-axios/v2" import { DEFAULT_RESOURCE_IMG } from "ol-utilities" import { useFeatureFlagsLoaded } from "@/common/useFeatureFlagsLoaded" @@ -29,11 +29,6 @@ type CoursePageProps = { readableId: string } -const WhatSection = styled.section({ - display: "flex", - flexDirection: "column", - gap: "16px", -}) const PrerequisitesSection = styled.section({ display: "flex", flexDirection: "column", @@ -54,6 +49,12 @@ const getNavLinks = (page: CoursePageItem): HeadingData[] => { variant: "secondary", content: page.what_you_learn, }, + { + id: HeadingIds.How, + label: "How you'll learn", + variant: "secondary", + content: true, + }, { id: HeadingIds.Prereqs, label: "Prerequisites", @@ -107,40 +108,30 @@ const CoursePage: React.FC = ({ readableId }) => { title={page.title} shortDescription={page.course_details.page.description} imageSrc={imageSrc} - sidebarSummary={ - } - /> - } - navLinks={navLinks} + summaryTitle="Course summary" + sidebarSummary={} + enrollButton={} + navbar={} > - - - {page.about ? ( - - ) : null} - {page.what_you_learn ? ( - - - What you'll learn - - - - ) : null} - {page.prerequisites ? ( - - - Prerequisites - - - - ) : null} - {page.faculty.length ? ( - - ) : null} - - + {page.about ? ( + + ) : null} + {page.what_you_learn ? ( + + ) : null} + + {page.prerequisites ? ( + + + Prerequisites + + + + ) : null} + {page.faculty.length ? ( + + ) : null} + ) } diff --git a/frontends/main/src/app-pages/ProductPages/HowYoullLearnSection.tsx b/frontends/main/src/app-pages/ProductPages/HowYoullLearnSection.tsx new file mode 100644 index 0000000000..42502b3bce --- /dev/null +++ b/frontends/main/src/app-pages/ProductPages/HowYoullLearnSection.tsx @@ -0,0 +1,142 @@ +import React from "react" +import { Typography } from "ol-components" +import { styled } from "@mitodl/smoot-design" +import Image from "next/image" +import { HeadingIds } from "./util" + +import IconBookPlay from "@/public/images/product/icon_book_play.png" +import IconBrains from "@/public/images/product/icon_brains.png" +import IconCertificate from "@/public/images/product/icon_certificate.png" +import IconComputerBulb from "@/public/images/product/icon_computer_lightbulb.png" +import IconConnectedPeople from "@/public/images/product/icon_connected_people.png" + +const HowYoullLearnRoot = styled.section(({ theme }) => ({ + display: "flex", + flexDirection: "column", + gap: "32px", + [theme.breakpoints.down("md")]: { + gap: "24px", + }, +})) + +const HowYoullLearnGrid = styled.ul(({ theme }) => ({ + display: "grid", + gridTemplateColumns: "1fr 1fr", + gap: "32px 56px", + listStyle: "none", + padding: 0, + margin: 0, + [theme.breakpoints.down("md")]: { + gap: "16px", + }, + [theme.breakpoints.down("sm")]: { + gridTemplateColumns: "1fr", + gap: "24px", + }, +})) + +const HowYoullLearnItem = styled.li(({ theme }) => ({ + display: "flex", + flexDirection: "column", + gap: "16px", + border: `1px solid ${theme.custom.colors.lightGray2}`, + borderRadius: "4px", + padding: "24px", +})) + +const HowYoullLearnHeader = styled.div({ + display: "flex", + gap: "16px", + alignItems: "center", +}) + +const HowYoullLearnIcon = styled(Image)({ + width: "80px", + height: "50px", + flexShrink: 0, +}) + +const HowYoullLearnTitle = styled.strong(({ theme }) => ({ + ...theme.typography.subtitle1, +})) + +const HowYoullLearnDescription = styled.p(({ theme }) => ({ + ...theme.typography.body1, + lineHeight: "1.5", + margin: 0, + [theme.breakpoints.down("md")]: { + ...theme.typography.body2, + }, +})) + +type HowYoullLearnItemData = { + icon: typeof IconComputerBulb + title: string + text: string +} + +// Placeholder data — will be replaced by API-driven content. +const DEFAULT_HOW_DATA: HowYoullLearnItemData[] = [ + { + icon: IconComputerBulb, + title: "Learn by doing", + text: "Practice processes and methods through simulations, assessments, case studies, and tools.", + }, + { + icon: IconConnectedPeople, + title: "Learn from others", + text: "Connect with an international community of professionals while working on projects based on real-world examples.", + }, + { + icon: IconBookPlay, + title: "Learn on demand", + text: "Access course content online and watch videos at your own pace.", + }, + { + icon: IconBrains, + title: "Reflect and apply", + text: "Bring new skills to your organization through real-world examples and prompts for reflection.", + }, + { + icon: IconCertificate, + title: "Demonstrate your success", + text: "Earn a credential from MIT to showcase your achievement.", + }, + { + icon: IconConnectedPeople, + title: "Learn from the best", + text: "Gain insights from MIT faculty and industry experts.", + }, +] + +const HowYoullLearnSection: React.FC<{ + data: HowYoullLearnItemData[] +}> = ({ data }) => { + return ( + + + How you'll learn + + + {data.map((item, index) => ( + + + + {item.title} + + {item.text} + + ))} + + + ) +} + +export default HowYoullLearnSection +export { DEFAULT_HOW_DATA } +export type { HowYoullLearnItemData } diff --git a/frontends/main/src/app-pages/ProductPages/ProductNavbar.tsx b/frontends/main/src/app-pages/ProductPages/ProductNavbar.tsx new file mode 100644 index 0000000000..28447e1e7a --- /dev/null +++ b/frontends/main/src/app-pages/ProductPages/ProductNavbar.tsx @@ -0,0 +1,69 @@ +import React from "react" +import { ButtonLink, styled } from "@mitodl/smoot-design" +import { HeadingIds } from "./util" +import type { ProductNoun } from "./util" + +const StyledLink = styled(ButtonLink)(({ theme }) => ({ + backgroundColor: theme.custom.colors.white, + borderColor: theme.custom.colors.white, + [theme.breakpoints.down("md")]: { + backgroundColor: theme.custom.colors.lightGray1, + border: `1px solid ${theme.custom.colors.lightGray2}`, + }, +})) + +const LinksContainer = styled.nav(({ theme }) => ({ + display: "flex", + flexWrap: "wrap", + [theme.breakpoints.up("md")]: { + gap: "24px", + padding: "12px 16px", + backgroundColor: theme.custom.colors.white, + borderRadius: "4px", + border: `1px solid ${theme.custom.colors.lightGray2}`, + boxShadow: "0 8px 20px 0 rgba(120, 147, 172, 0.10)", + marginTop: "-24px", + width: "calc(100%)", + }, + [theme.breakpoints.down("md")]: { + alignSelf: "center", + gap: "8px", + rowGap: "16px", + padding: 0, + }, +})) + +export type HeadingData = { + id: HeadingIds + label: string + variant: "primary" | "secondary" +} + +const ProductNavbar: React.FC<{ + navLinks: HeadingData[] + productNoun: ProductNoun +}> = ({ navLinks, productNoun }) => { + if (navLinks.length === 0) { + return null + } + return ( + + {navLinks.map((heading) => { + const LinkComponent = + heading.variant === "primary" ? ButtonLink : StyledLink + return ( + + {heading.label} + + ) + })} + + ) +} + +export default ProductNavbar diff --git a/frontends/main/src/app-pages/ProductPages/ProductPageTemplate.tsx b/frontends/main/src/app-pages/ProductPages/ProductPageTemplate.tsx index 7403c8cfa0..b51f601ee7 100644 --- a/frontends/main/src/app-pages/ProductPages/ProductPageTemplate.tsx +++ b/frontends/main/src/app-pages/ProductPages/ProductPageTemplate.tsx @@ -11,9 +11,10 @@ import { import { backgroundSrcSetCSS } from "ol-utilities" import { HOME } from "@/common/urls" import backgroundSteps from "@/public/images/backgrounds/background_steps.jpg" -import { ButtonLink, styled } from "@mitodl/smoot-design" +import { styled, VisuallyHidden } from "@mitodl/smoot-design" import Image from "next/image" import { HeadingIds } from "./util" +import type { Breakpoint } from "@mui/system" const StyledBreadcrumbs = styled(Breadcrumbs)(({ theme }) => ({ paddingBottom: "24px", @@ -59,103 +60,96 @@ const BottomContainer = styled(Container)(({ theme }) => ({ flexDirection: "column", alignItems: "center", gap: "0px", + paddingTop: "24px", }, })) +const SHOW_PROPS = new Set(["showAbove", "showBelow", "showBetween"]) +/** Responsive visibility helper. Only one of showAbove/showBelow/showBetween should be used. */ +const Show = styled("div", { + shouldForwardProp: (prop) => !SHOW_PROPS.has(prop), +})<{ + showAbove?: Breakpoint + showBelow?: Breakpoint + showBetween?: [Breakpoint, Breakpoint] +}>(({ theme, showAbove, showBelow, showBetween }) => ({ + ...(showAbove && { + [theme.breakpoints.down(showAbove)]: { display: "none" }, + }), + ...(showBelow && { + [theme.breakpoints.up(showBelow)]: { display: "none" }, + }), + ...(showBetween && { + [theme.breakpoints.down(showBetween[0])]: { display: "none" }, + [theme.breakpoints.up(showBetween[1])]: { display: "none" }, + }), +})) + const MainCol = styled.div({ flex: 1, display: "flex", flexDirection: "column", + minWidth: 0, }) -const SidebarCol = styled.div(({ theme }) => ({ - width: "100%", - maxWidth: "410px", - [theme.breakpoints.down("md")]: { - marginTop: "24px", - }, -})) -const SidebarSpacer = styled.div(({ theme }) => ({ - width: "410px", - [theme.breakpoints.down("md")]: { - display: "none", - }, -})) - -const StyledLink = styled(ButtonLink)(({ theme }) => ({ - backgroundColor: theme.custom.colors.white, - borderColor: theme.custom.colors.white, - [theme.breakpoints.down("md")]: { - backgroundColor: theme.custom.colors.lightGray1, - border: `1px solid ${theme.custom.colors.lightGray2}`, - }, -})) - -const LinksContainer = styled.nav(({ theme }) => ({ +const SectionsWrapper = styled.div(({ theme }) => ({ display: "flex", - flexWrap: "wrap", - [theme.breakpoints.up("md")]: { - gap: "24px", - padding: "12px 16px", - backgroundColor: theme.custom.colors.white, - borderRadius: "4px", - border: `1px solid ${theme.custom.colors.lightGray2}`, - boxShadow: "0 8px 20px 0 rgba(120, 147, 172, 0.10)", - marginTop: "-24px", - width: "calc(100%)", - marginBottom: "40px", - }, + flexDirection: "column", + gap: "56px", + marginTop: "40px", [theme.breakpoints.down("md")]: { - alignSelf: "center", - gap: "8px", - rowGap: "16px", - padding: 0, - margin: "24px 0", + gap: "40px", + marginTop: "36px", }, })) -const SidebarImageWrapper = styled.div(({ theme }) => ({ - [theme.breakpoints.up("md")]: { - height: "0px", - }, +const SidebarCol = styled(Show, { + shouldForwardProp: (prop) => prop !== "alignSelf", +})<{ + alignSelf?: React.CSSProperties["alignSelf"] +}>(({ alignSelf }) => ({ + width: "100%", + maxWidth: "410px", + alignSelf, })) + const SidebarImage = styled(Image)(({ theme }) => ({ borderRadius: "4px", width: "100%", maxWidth: "410px", - height: "230px", + aspectRatio: "410 / 230", + height: "auto", display: "block", - [theme.breakpoints.up("md")]: { - transform: "translateY(-100%)", - }, [theme.breakpoints.down("md")]: { border: `1px solid ${theme.custom.colors.lightGray2}`, borderRadius: "4px 4px 0 0", }, })) -const WhoCanTakeSection = styled.section(({ theme }) => ({ - padding: "32px", +const SummaryRoot = styled.div(({ theme }) => ({ border: `1px solid ${theme.custom.colors.lightGray2}`, - borderRadius: "8px", + backgroundColor: theme.custom.colors.white, + borderRadius: "4px", + boxShadow: "0 8px 20px 0 rgba(120, 147, 172, 0.10)", + padding: "24px", display: "flex", flexDirection: "column", - gap: "24px", - ...theme.typography.body1, - lineHeight: "1.5", + gap: "32px", + [theme.breakpoints.up("md")]: { + position: "sticky", + marginTop: "-54px", + top: "calc(40px + 32px + 24px)", + borderRadius: "4px", + }, + [theme.breakpoints.between("sm", "md")]: { + flexDirection: "row", + gap: "48px", + }, [theme.breakpoints.down("md")]: { - padding: "16px", - gap: "16px", - ...theme.typography.body2, + marginTop: "24px", }, })) -type HeadingData = { - id: HeadingIds - label: string - variant: "primary" | "secondary" -} - type ProductPageTemplateProps = { tags: string[] currentBreadcrumbLabel: string @@ -163,8 +157,10 @@ type ProductPageTemplateProps = { shortDescription: React.ReactNode imageSrc: string sidebarSummary: React.ReactNode + summaryTitle: string children: React.ReactNode - navLinks: HeadingData[] + navbar: React.ReactNode + enrollButton?: React.ReactNode } const ProductPageTemplate: React.FC = ({ tags, @@ -173,7 +169,10 @@ const ProductPageTemplate: React.FC = ({ shortDescription, imageSrc, sidebarSummary, + summaryTitle, children, + enrollButton, + navbar, }) => { return ( @@ -201,64 +200,56 @@ const ProductPageTemplate: React.FC = ({ - + + + - - - - - {sidebarSummary} + {/* + * The summary section is rendered 3 times (desktop, tablet, mobile) + * with different layouts, but only one is visible at a time via CSS. + * A single visually-hidden heading serves all three. + */} + +

{summaryTitle}

+
+ + + {enrollButton} + {sidebarSummary} + - {children} + + {navbar} + + + {sidebarSummary} + + + {enrollButton} + + + + + + + {enrollButton} + {sidebarSummary} + + + {children} +
) } -const ProductNavbar: React.FC<{ - navLinks: HeadingData[] - productNoun: string -}> = ({ navLinks, productNoun }) => { - if (navLinks.length === 0) { - return null - } - return ( - - {navLinks.map((heading) => { - const LinkComponent = - heading.variant === "primary" ? ButtonLink : StyledLink - return ( - - {heading.label} - - ) - })} - - ) -} - -const WhoCanTake: React.FC<{ productNoun: string }> = ({ productNoun }) => { - return ( - - - Who can take this {productNoun}? - - Because of U.S. Office of Foreign Assets Control (OFAC) restrictions and - other U.S. federal regulations, learners residing in one or more of the - following countries or regions will not be able to register for this - course: Iran, Cuba, Syria, North Korea and the Crimea, Donetsk People's - Republic and Luhansk People's Republic regions of Ukraine. - - ) -} - export default ProductPageTemplate -export { WhoCanTake, ProductNavbar } -export type { HeadingData, ProductPageTemplateProps } +export type { ProductPageTemplateProps } diff --git a/frontends/main/src/app-pages/ProductPages/ProductSummary.test.tsx b/frontends/main/src/app-pages/ProductPages/ProductSummary.test.tsx index 6bc5146a95..7ebc3a6b16 100644 --- a/frontends/main/src/app-pages/ProductPages/ProductSummary.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/ProductSummary.test.tsx @@ -15,14 +15,6 @@ const makeFlexiblePrice = factories.products.flexiblePrice const { RequirementTreeBuilder } = factories.requirements describe("CourseSummary", () => { - test("renders course summary", async () => { - const course = makeCourse() - renderWithProviders() - - const summary = screen.getByRole("region", { name: "Course summary" }) - within(summary).getByRole("heading", { name: "Course summary" }) - }) - test.each([ { overrides: { next_run_id: null }, expectAlert: true }, { overrides: {}, expectAlert: false }, @@ -31,8 +23,7 @@ describe("CourseSummary", () => { ({ overrides, expectAlert }) => { const course = makeCourse(overrides) renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const alertMessage = within(summary).queryByText( + const alertMessage = screen.queryByText( /No sessions of this course are currently open for enrollment/, ) @@ -58,8 +49,7 @@ describe("CourseSummary", () => { courseruns: shuffle([run, makeRun()]), }) renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const alert = within(summary).queryByRole("alert") + const alert = screen.queryByRole("alert") if (expectAlert) { invariant(alert) @@ -72,629 +62,627 @@ describe("CourseSummary", () => { }, ) - test("Renders enrollButton prop when provided", () => { - const run = makeRun() - const course = makeCourse({ - next_run_id: run.id, - courseruns: shuffle([run, makeRun()]), - }) - const enrollButton = - renderWithProviders( - , - ) - const summary = screen.getByRole("region", { name: "Course summary" }) - const button = within(summary).getByRole("button", { - name: "Test Enroll Button", - }) - expect(button).toBeInTheDocument() - }) -}) + describe("Dates Row", () => { + test("Renders expected start/end dates", async () => { + const run = makeRun({ is_enrollable: true }) + const nonEnrollableRun = makeRun({ is_enrollable: false }) + const course = makeCourse({ + availability: "dated", + next_run_id: run.id, + courseruns: shuffle([run, nonEnrollableRun]), + }) + renderWithProviders() + + const datesRow = screen.getByTestId(TestIds.DatesRow) -describe("Course Dates Row", () => { - test("Renders expected start/end dates", async () => { - const run = makeRun({ is_enrollable: true }) - const nonEnrollableRun = makeRun({ is_enrollable: false }) - const course = makeCourse({ - availability: "dated", - next_run_id: run.id, - courseruns: shuffle([run, nonEnrollableRun]), + invariant(run.start_date) + expect(datesRow).toHaveTextContent(`Start: ${formatDate(run.start_date)}`) + + invariant(run.end_date) + expect(datesRow).toHaveTextContent(`End: ${formatDate(run.end_date)}`) }) - renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const datesRow = within(summary).getByTestId(TestIds.DatesRow) + test("Renders 'Start: Anytime' plus and end date for start anytime courses", () => { + // For "Anytime" to show, run must be self-paced, not archived, and have a start date in the past + const run = makeRun({ + start_date: "2025-01-01", // Past date + is_self_paced: true, + is_archived: false, + is_enrollable: true, + }) + const course = makeCourse({ + availability: "anytime", + courseruns: [run], + next_run_id: run.id, + }) + renderWithProviders() - invariant(run.start_date) - expect(datesRow).toHaveTextContent(`Start: ${formatDate(run.start_date)}`) + const datesRow = screen.getByTestId(TestIds.DatesRow) - invariant(run.end_date) - expect(datesRow).toHaveTextContent(`End: ${formatDate(run.end_date)}`) - }) + expect(datesRow).toHaveTextContent("Start: Anytime") - test("Renders 'Start: Anytime' plus and end date for start anytime courses", () => { - // For "Anytime" to show, run must be self-paced, not archived, and have a start date in the past - const run = makeRun({ - start_date: "2025-01-01", // Past date - is_self_paced: true, - is_archived: false, - is_enrollable: true, - }) - const course = makeCourse({ - availability: "anytime", - courseruns: [run], - next_run_id: run.id, + invariant(run.end_date) + expect(datesRow).toHaveTextContent(`End: ${formatDate(run.end_date)}`) }) - renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const datesRow = within(summary).getByTestId(TestIds.DatesRow) - - expect(datesRow).toHaveTextContent("Start: Anytime") + test("Renders nothing if next run has no start date and is only enrollable run", () => { + const run = makeRun({ start_date: null, is_enrollable: true }) + const nonEnrollableRun = makeRun({ is_enrollable: false }) + const course = makeCourse({ + availability: "dated", + courseruns: shuffle([run, nonEnrollableRun]), + next_run_id: run.id, + }) + renderWithProviders() - invariant(run.end_date) - expect(datesRow).toHaveTextContent(`End: ${formatDate(run.end_date)}`) - }) + const datesRow = screen.getByTestId(TestIds.DatesRow) - test("Renders nothing if next run has no start date and is only enrollable run", () => { - const run = makeRun({ start_date: null, is_enrollable: true }) - const nonEnrollableRun = makeRun({ is_enrollable: false }) - const course = makeCourse({ - availability: "dated", - courseruns: shuffle([run, nonEnrollableRun]), - next_run_id: run.id, + // Dates row exists but shows no date information + expect(datesRow).not.toHaveTextContent("Start") + expect(datesRow).not.toHaveTextContent("End") }) - renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const datesRow = within(summary).getByTestId(TestIds.DatesRow) + test("Renders no end date if end date missing", () => { + const run = makeRun({ end_date: null }) + const course = makeCourse({ + availability: "dated", + courseruns: shuffle([run, makeRun()]), + next_run_id: run.id, + }) + renderWithProviders() - // Dates row exists but shows no date information - expect(datesRow).not.toHaveTextContent("Start") - expect(datesRow).not.toHaveTextContent("End") - }) + const datesRow = screen.getByTestId(TestIds.DatesRow) - test("Renders no end date if end date missing", () => { - const run = makeRun({ end_date: null }) - const course = makeCourse({ - availability: "dated", - courseruns: shuffle([run, makeRun()]), - next_run_id: run.id, + expect(datesRow).not.toHaveTextContent("End") }) - renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const datesRow = within(summary).getByTestId(TestIds.DatesRow) + test("Displays a single date without 'More Dates' toggle when only one enrollable run", () => { + const run = makeRun({ + is_enrollable: true, + start_date: "2026-03-01", + end_date: "2026-05-01", + }) + const nonEnrollableRun = makeRun({ + is_enrollable: false, + start_date: "2025-01-01", + end_date: "2025-03-01", + }) + const course = makeCourse({ + next_run_id: run.id, + courseruns: [run, nonEnrollableRun], + }) + renderWithProviders() - expect(datesRow).not.toHaveTextContent("End") - }) + const datesRow = screen.getByTestId(TestIds.DatesRow) - test("Displays a single date without 'More Dates' toggle when only one enrollable run", () => { - const run = makeRun({ - is_enrollable: true, - start_date: "2026-03-01", - end_date: "2026-05-01", - }) - const nonEnrollableRun = makeRun({ - is_enrollable: false, - start_date: "2025-01-01", - end_date: "2025-03-01", - }) - const course = makeCourse({ - next_run_id: run.id, - courseruns: [run, nonEnrollableRun], - }) - renderWithProviders() - - const summary = screen.getByRole("region", { name: "Course summary" }) - const datesRow = within(summary).getByTestId(TestIds.DatesRow) - - // Should show the start and end dates - invariant(run.start_date) - expect(datesRow).toHaveTextContent(`Start: ${formatDate(run.start_date)}`) - invariant(run.end_date) - expect(datesRow).toHaveTextContent(`End: ${formatDate(run.end_date)}`) - - // Should NOT have a "More Dates" toggle - expect( - within(datesRow).queryByRole("button", { name: /More Dates/i }), - ).toBeNull() - expect(datesRow).not.toHaveTextContent("Dates Available") - }) + // Should show the start and end dates + invariant(run.start_date) + expect(datesRow).toHaveTextContent(`Start: ${formatDate(run.start_date)}`) + invariant(run.end_date) + expect(datesRow).toHaveTextContent(`End: ${formatDate(run.end_date)}`) - test("Displays 'More Dates' toggle when multiple enrollable runs exist", () => { - const run1 = makeRun({ - is_enrollable: true, - start_date: "2026-03-01", - end_date: "2026-05-01", - }) - const run2 = makeRun({ - is_enrollable: true, - start_date: "2026-06-01", - end_date: "2026-08-01", + // Should NOT have a "More Dates" toggle + expect( + within(datesRow).queryByRole("button", { name: /More Dates/i }), + ).toBeNull() + expect(datesRow).not.toHaveTextContent("Dates Available") }) - const course = makeCourse({ - next_run_id: run1.id, - courseruns: [run1, run2], - }) - renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const datesRow = within(summary).getByTestId(TestIds.DatesRow) + test("Displays 'More Dates' toggle when multiple enrollable runs exist", () => { + const run1 = makeRun({ + is_enrollable: true, + start_date: "2026-03-01", + end_date: "2026-05-01", + }) + const run2 = makeRun({ + is_enrollable: true, + start_date: "2026-06-01", + end_date: "2026-08-01", + }) + const course = makeCourse({ + next_run_id: run1.id, + courseruns: [run1, run2], + }) + renderWithProviders() - // Should show "Dates Available" label - expect(datesRow).toHaveTextContent("Dates Available") + const datesRow = screen.getByTestId(TestIds.DatesRow) - // Should show "More Dates" button - const moreDatesButton = within(datesRow).getByRole("button", { - name: "More Dates", - }) - expect(moreDatesButton).toBeInTheDocument() - }) + // Should show "Dates Available" label + expect(datesRow).toHaveTextContent("Dates Available") - test("Clicking 'More Dates' expands to show all enrollable dates, clicking 'Show Less' collapses back", async () => { - const run1 = makeRun({ - is_enrollable: true, - start_date: "2026-03-01", - end_date: "2026-05-01", - }) - const run2 = makeRun({ - is_enrollable: true, - start_date: "2026-06-01", - end_date: "2026-08-01", - }) - const run3 = makeRun({ - is_enrollable: true, - start_date: "2026-09-01", - end_date: "2026-11-01", - }) - const course = makeCourse({ - next_run_id: run1.id, - courseruns: [run1, run2, run3], + // Should show "More Dates" button + const moreDatesButton = within(datesRow).getByRole("button", { + name: "More Dates", + }) + expect(moreDatesButton).toBeInTheDocument() }) - renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const datesRow = within(summary).getByTestId(TestIds.DatesRow) + test("Clicking 'More Dates' expands to show all enrollable dates, clicking 'Show Less' collapses back", async () => { + const run1 = makeRun({ + is_enrollable: true, + start_date: "2026-03-01", + end_date: "2026-05-01", + }) + const run2 = makeRun({ + is_enrollable: true, + start_date: "2026-06-01", + end_date: "2026-08-01", + }) + const run3 = makeRun({ + is_enrollable: true, + start_date: "2026-09-01", + end_date: "2026-11-01", + }) + const course = makeCourse({ + next_run_id: run1.id, + courseruns: [run1, run2, run3], + }) + renderWithProviders() - // Initially, only the next_run date should be visible - invariant(run1.start_date) - expect(datesRow).toHaveTextContent(formatDate(run1.start_date)) + const datesRow = screen.getByTestId(TestIds.DatesRow) - // Other dates should NOT be visible - invariant(run2.start_date) - invariant(run3.start_date) - expect(datesRow).not.toHaveTextContent(formatDate(run2.start_date)) - expect(datesRow).not.toHaveTextContent(formatDate(run3.start_date)) + // Initially, only the next_run date should be visible + invariant(run1.start_date) + expect(datesRow).toHaveTextContent(formatDate(run1.start_date)) - // Click "More Dates" - const moreDatesButton = within(datesRow).getByRole("button", { - name: "More Dates", - }) - await user.click(moreDatesButton) + // Other dates should NOT be visible + invariant(run2.start_date) + invariant(run3.start_date) + expect(datesRow).not.toHaveTextContent(formatDate(run2.start_date)) + expect(datesRow).not.toHaveTextContent(formatDate(run3.start_date)) - // Now all dates should be visible - expect(datesRow).toHaveTextContent(formatDate(run1.start_date)) - expect(datesRow).toHaveTextContent(formatDate(run2.start_date)) - expect(datesRow).toHaveTextContent(formatDate(run3.start_date)) + // Click "More Dates" + const moreDatesButton = within(datesRow).getByRole("button", { + name: "More Dates", + }) + await user.click(moreDatesButton) - // Button text should change to "Show Less" - const fewerDatesButton = within(datesRow).getByRole("button", { - name: "Show Less", - }) - expect(fewerDatesButton).toBeInTheDocument() + // Now all dates should be visible + expect(datesRow).toHaveTextContent(formatDate(run1.start_date)) + expect(datesRow).toHaveTextContent(formatDate(run2.start_date)) + expect(datesRow).toHaveTextContent(formatDate(run3.start_date)) - // Click "Show Less" to collapse - await user.click(fewerDatesButton) + // Button text should change to "Show Less" + const fewerDatesButton = within(datesRow).getByRole("button", { + name: "Show Less", + }) + expect(fewerDatesButton).toBeInTheDocument() - // Should be back to showing only the next_run date - expect(datesRow).toHaveTextContent(formatDate(run1.start_date)) - expect(datesRow).not.toHaveTextContent(formatDate(run2.start_date)) - expect(datesRow).not.toHaveTextContent(formatDate(run3.start_date)) + // Click "Show Less" to collapse + await user.click(fewerDatesButton) - // Button should be back to "More Dates" - expect( - within(datesRow).getByRole("button", { name: "More Dates" }), - ).toBeInTheDocument() - }) + // Should be back to showing only the next_run date + expect(datesRow).toHaveTextContent(formatDate(run1.start_date)) + expect(datesRow).not.toHaveTextContent(formatDate(run2.start_date)) + expect(datesRow).not.toHaveTextContent(formatDate(run3.start_date)) - test("Multiple enrollable dates are displayed chronologically newest to oldest", async () => { - const oldestRun = makeRun({ - is_enrollable: true, - is_self_paced: true, // This will show "Start: Anytime" (past date) - is_archived: false, - start_date: "2025-01-01", // Past date - end_date: "2026-03-01", - }) - const middleRun = makeRun({ - is_enrollable: true, - is_self_paced: false, // Ensure dates display normally (not "Anytime") - is_archived: false, - start_date: "2026-06-01", - end_date: "2026-08-01", - }) - const newestRun = makeRun({ - is_enrollable: true, - is_self_paced: false, // Ensure dates display normally (not "Anytime") - is_archived: false, - start_date: "2026-09-01", - end_date: "2026-11-01", + // Button should be back to "More Dates" + expect( + within(datesRow).getByRole("button", { name: "More Dates" }), + ).toBeInTheDocument() }) - const course = makeCourse({ - next_run_id: middleRun.id, - // Shuffle the runs to ensure sorting works regardless of input order - courseruns: shuffle([oldestRun, middleRun, newestRun]), - }) - renderWithProviders() + test("Multiple enrollable dates are displayed chronologically newest to oldest", async () => { + const oldestRun = makeRun({ + is_enrollable: true, + is_self_paced: true, // This will show "Start: Anytime" (past date) + is_archived: false, + start_date: "2025-01-01", // Past date + end_date: "2026-03-01", + }) + const middleRun = makeRun({ + is_enrollable: true, + is_self_paced: false, // Ensure dates display normally (not "Anytime") + is_archived: false, + start_date: "2026-06-01", + end_date: "2026-08-01", + }) + const newestRun = makeRun({ + is_enrollable: true, + is_self_paced: false, // Ensure dates display normally (not "Anytime") + is_archived: false, + start_date: "2026-09-01", + end_date: "2026-11-01", + }) - const summary = screen.getByRole("region", { name: "Course summary" }) - const datesRow = within(summary).getByTestId(TestIds.DatesRow) + const course = makeCourse({ + next_run_id: middleRun.id, + // Shuffle the runs to ensure sorting works regardless of input order + courseruns: shuffle([oldestRun, middleRun, newestRun]), + }) + renderWithProviders() - // Click "More Dates" to show all dates - const moreDatesButton = within(datesRow).getByRole("button", { - name: "More Dates", - }) - await user.click(moreDatesButton) + const datesRow = screen.getByTestId(TestIds.DatesRow) - // Get all the date strings that should appear - invariant(newestRun.start_date) - invariant(middleRun.start_date) - invariant(oldestRun.start_date) + // Click "More Dates" to show all dates + const moreDatesButton = within(datesRow).getByRole("button", { + name: "More Dates", + }) + await user.click(moreDatesButton) - const newestDateString = formatDate(newestRun.start_date) - const middleDateString = formatDate(middleRun.start_date) - const oldestDateString = formatDate(oldestRun.start_date) + // Get all the date strings that should appear + invariant(newestRun.start_date) + invariant(middleRun.start_date) + invariant(oldestRun.start_date) - // Get the date entry containers - const dateEntryContainers = within(datesRow).getAllByTestId("date-entry") + const newestDateString = formatDate(newestRun.start_date) + const middleDateString = formatDate(middleRun.start_date) + const oldestDateString = formatDate(oldestRun.start_date) - // Verify we have 3 date entries - expect(dateEntryContainers).toHaveLength(3) + // Get the date entry containers + const dateEntryContainers = within(datesRow).getAllByTestId("date-entry") - // Verify the dates appear in chronological order (newest to oldest) - const firstDateContainer = dateEntryContainers[0] - const secondDateContainer = dateEntryContainers[1] - const thirdDateContainer = dateEntryContainers[2] + // Verify we have 3 date entries + expect(dateEntryContainers).toHaveLength(3) - expect(firstDateContainer?.textContent).toContain(newestDateString) - expect(secondDateContainer?.textContent).toContain(middleDateString) - // The oldest run should show "Anytime" instead of the actual date - expect(thirdDateContainer?.textContent).toContain("Start: Anytime") - expect(thirdDateContainer?.textContent).not.toContain(oldestDateString) - }) + // Verify the dates appear in chronological order (newest to oldest) + const firstDateContainer = dateEntryContainers[0] + const secondDateContainer = dateEntryContainers[1] + const thirdDateContainer = dateEntryContainers[2] - test("Initially displays the date for the run with next_run_id when multiple enrollable runs exist", () => { - const run1 = makeRun({ - is_enrollable: true, - start_date: "2026-01-01", - end_date: "2026-03-01", - }) - const run2 = makeRun({ - is_enrollable: true, - start_date: "2026-06-01", - end_date: "2026-08-01", - }) - const run3 = makeRun({ - is_enrollable: true, - start_date: "2026-09-01", - end_date: "2026-11-01", + expect(firstDateContainer?.textContent).toContain(newestDateString) + expect(secondDateContainer?.textContent).toContain(middleDateString) + // The oldest run should show "Anytime" instead of the actual date + expect(thirdDateContainer?.textContent).toContain("Start: Anytime") + expect(thirdDateContainer?.textContent).not.toContain(oldestDateString) }) - const course = makeCourse({ - next_run_id: run2.id, // Select the middle run as next - courseruns: shuffle([run1, run2, run3]), - }) - renderWithProviders() - - const summary = screen.getByRole("region", { name: "Course summary" }) - const datesRow = within(summary).getByTestId(TestIds.DatesRow) - - // Should show run2's date (the next_run_id) - invariant(run2.start_date) - invariant(run2.end_date) - expect(datesRow).toHaveTextContent(formatDate(run2.start_date)) - expect(datesRow).toHaveTextContent(formatDate(run2.end_date)) - - // Should NOT show other runs' dates initially - invariant(run1.start_date) - invariant(run3.start_date) - expect(datesRow).not.toHaveTextContent(formatDate(run1.start_date)) - expect(datesRow).not.toHaveTextContent(formatDate(run3.start_date)) - }) + test("Initially displays the date for the run with next_run_id when multiple enrollable runs exist", () => { + const run1 = makeRun({ + is_enrollable: true, + start_date: "2026-01-01", + end_date: "2026-03-01", + }) + const run2 = makeRun({ + is_enrollable: true, + start_date: "2026-06-01", + end_date: "2026-08-01", + }) + const run3 = makeRun({ + is_enrollable: true, + start_date: "2026-09-01", + end_date: "2026-11-01", + }) - test("Never displays dates for non-enrollable runs", async () => { - const enrollableRun = makeRun({ - is_enrollable: true, - start_date: "2026-06-01", - end_date: "2026-08-01", - }) - const nonEnrollableRun1 = makeRun({ - is_enrollable: false, - start_date: "2026-01-01", - end_date: "2026-03-01", - }) - const nonEnrollableRun2 = makeRun({ - is_enrollable: false, - start_date: "2026-09-01", - end_date: "2026-11-01", - }) + const course = makeCourse({ + next_run_id: run2.id, // Select the middle run as next + courseruns: shuffle([run1, run2, run3]), + }) + renderWithProviders() - const course = makeCourse({ - next_run_id: enrollableRun.id, - courseruns: shuffle([ - enrollableRun, - nonEnrollableRun1, - nonEnrollableRun2, - ]), + const datesRow = screen.getByTestId(TestIds.DatesRow) + + // Should show run2's date (the next_run_id) + invariant(run2.start_date) + invariant(run2.end_date) + expect(datesRow).toHaveTextContent(formatDate(run2.start_date)) + expect(datesRow).toHaveTextContent(formatDate(run2.end_date)) + + // Should NOT show other runs' dates initially + invariant(run1.start_date) + invariant(run3.start_date) + expect(datesRow).not.toHaveTextContent(formatDate(run1.start_date)) + expect(datesRow).not.toHaveTextContent(formatDate(run3.start_date)) }) - renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const datesRow = within(summary).getByTestId(TestIds.DatesRow) + test("Never displays dates for non-enrollable runs", async () => { + const enrollableRun = makeRun({ + is_enrollable: true, + start_date: "2026-06-01", + end_date: "2026-08-01", + }) + const nonEnrollableRun1 = makeRun({ + is_enrollable: false, + start_date: "2026-01-01", + end_date: "2026-03-01", + }) + const nonEnrollableRun2 = makeRun({ + is_enrollable: false, + start_date: "2026-09-01", + end_date: "2026-11-01", + }) - // Should show only the enrollable run's date - invariant(enrollableRun.start_date) - expect(datesRow).toHaveTextContent(formatDate(enrollableRun.start_date)) + const course = makeCourse({ + next_run_id: enrollableRun.id, + courseruns: shuffle([ + enrollableRun, + nonEnrollableRun1, + nonEnrollableRun2, + ]), + }) + renderWithProviders() - // Should NOT show non-enrollable runs' dates - invariant(nonEnrollableRun1.start_date) - invariant(nonEnrollableRun2.start_date) - expect(datesRow).not.toHaveTextContent( - formatDate(nonEnrollableRun1.start_date), - ) - expect(datesRow).not.toHaveTextContent( - formatDate(nonEnrollableRun2.start_date), - ) + const datesRow = screen.getByTestId(TestIds.DatesRow) - // Should NOT have "More Dates" toggle since only one enrollable run - expect( - within(datesRow).queryByRole("button", { name: /More Dates/i }), - ).toBeNull() - }) + // Should show only the enrollable run's date + invariant(enrollableRun.start_date) + expect(datesRow).toHaveTextContent(formatDate(enrollableRun.start_date)) - test("Shows dates available header but no date entries when all enrollable runs have no start date", () => { - const run1 = makeRun({ - is_enrollable: true, - start_date: null, - }) - const run2 = makeRun({ - is_enrollable: true, - start_date: null, - }) - const course = makeCourse({ - next_run_id: run1.id, - courseruns: [run1, run2], + // Should NOT show non-enrollable runs' dates + invariant(nonEnrollableRun1.start_date) + invariant(nonEnrollableRun2.start_date) + expect(datesRow).not.toHaveTextContent( + formatDate(nonEnrollableRun1.start_date), + ) + expect(datesRow).not.toHaveTextContent( + formatDate(nonEnrollableRun2.start_date), + ) + + // Should NOT have "More Dates" toggle since only one enrollable run + expect( + within(datesRow).queryByRole("button", { name: /More Dates/i }), + ).toBeNull() }) - renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const datesRow = within(summary).getByTestId(TestIds.DatesRow) + test("Shows dates available header but no date entries when all enrollable runs have no start date", () => { + const run1 = makeRun({ + is_enrollable: true, + start_date: null, + }) + const run2 = makeRun({ + is_enrollable: true, + start_date: null, + }) + const course = makeCourse({ + next_run_id: run1.id, + courseruns: [run1, run2], + }) + renderWithProviders() - // Dates row renders because multiple enrollable runs exist - expect(datesRow).toHaveTextContent("Dates Available") + const datesRow = screen.getByTestId(TestIds.DatesRow) - // But no actual date information is shown - expect(datesRow).not.toHaveTextContent("Start") - expect(datesRow).not.toHaveTextContent("End") - }) + // Dates row renders because multiple enrollable runs exist + expect(datesRow).toHaveTextContent("Dates Available") - test("When multiple enrollable runs exist, only shows runs with start dates after expanding", async () => { - const runWithDate = makeRun({ - is_enrollable: true, - start_date: "2026-06-01", - end_date: "2026-08-01", - }) - const runWithoutDate = makeRun({ - is_enrollable: true, - start_date: null, - end_date: "2026-11-01", - }) - const anotherRunWithDate = makeRun({ - is_enrollable: true, - start_date: "2026-09-01", - end_date: "2026-11-01", + // But no actual date information is shown + expect(datesRow).not.toHaveTextContent("Start") + expect(datesRow).not.toHaveTextContent("End") }) - const course = makeCourse({ - next_run_id: runWithDate.id, - courseruns: shuffle([runWithDate, runWithoutDate, anotherRunWithDate]), - }) - renderWithProviders() + test("When multiple enrollable runs exist, only shows runs with start dates after expanding", async () => { + const runWithDate = makeRun({ + is_enrollable: true, + start_date: "2026-06-01", + end_date: "2026-08-01", + }) + const runWithoutDate = makeRun({ + is_enrollable: true, + start_date: null, + end_date: "2026-11-01", + }) + const anotherRunWithDate = makeRun({ + is_enrollable: true, + start_date: "2026-09-01", + end_date: "2026-11-01", + }) - const summary = screen.getByRole("region", { name: "Course summary" }) - const datesRow = within(summary).getByTestId(TestIds.DatesRow) + const course = makeCourse({ + next_run_id: runWithDate.id, + courseruns: shuffle([runWithDate, runWithoutDate, anotherRunWithDate]), + }) + renderWithProviders() - // Click "More Dates" - const moreDatesButton = within(datesRow).getByRole("button", { - name: "More Dates", - }) - await user.click(moreDatesButton) - - // Should show dates for runs with start dates - invariant(runWithDate.start_date) - invariant(anotherRunWithDate.start_date) - expect(datesRow).toHaveTextContent(formatDate(runWithDate.start_date)) - expect(datesRow).toHaveTextContent( - formatDate(anotherRunWithDate.start_date), - ) + const datesRow = screen.getByTestId(TestIds.DatesRow) - // The run without a start date should not appear - // Count the number of date entry containers - const dateEntryContainers = within(datesRow).getAllByTestId("date-entry") - expect(dateEntryContainers).toHaveLength(2) - }) + // Click "More Dates" + const moreDatesButton = within(datesRow).getByRole("button", { + name: "More Dates", + }) + await user.click(moreDatesButton) + + // Should show dates for runs with start dates + invariant(runWithDate.start_date) + invariant(anotherRunWithDate.start_date) + expect(datesRow).toHaveTextContent(formatDate(runWithDate.start_date)) + expect(datesRow).toHaveTextContent( + formatDate(anotherRunWithDate.start_date), + ) - test("End date is not displayed for runs without end date when expanded", async () => { - const runWithEndDate = makeRun({ - is_enrollable: true, - start_date: "2026-06-01", - end_date: "2026-08-01", - }) - const runWithoutEndDate = makeRun({ - is_enrollable: true, - start_date: "2026-09-01", - end_date: null, + // The run without a start date should not appear + // Count the number of date entry containers + const dateEntryContainers = within(datesRow).getAllByTestId("date-entry") + expect(dateEntryContainers).toHaveLength(2) }) - const course = makeCourse({ - next_run_id: runWithEndDate.id, - courseruns: [runWithEndDate, runWithoutEndDate], - }) - renderWithProviders() + test("End date is not displayed for runs without end date when expanded", async () => { + const runWithEndDate = makeRun({ + is_enrollable: true, + start_date: "2026-06-01", + end_date: "2026-08-01", + }) + const runWithoutEndDate = makeRun({ + is_enrollable: true, + start_date: "2026-09-01", + end_date: null, + }) - const summary = screen.getByRole("region", { name: "Course summary" }) - const datesRow = within(summary).getByTestId(TestIds.DatesRow) + const course = makeCourse({ + next_run_id: runWithEndDate.id, + courseruns: [runWithEndDate, runWithoutEndDate], + }) + renderWithProviders() - // Click "More Dates" - const moreDatesButton = within(datesRow).getByRole("button", { - name: "More Dates", - }) - await user.click(moreDatesButton) - - // Both start dates should be visible - invariant(runWithEndDate.start_date) - invariant(runWithoutEndDate.start_date) - expect(datesRow).toHaveTextContent(formatDate(runWithEndDate.start_date)) - expect(datesRow).toHaveTextContent(formatDate(runWithoutEndDate.start_date)) - - // Should only show one "End" label (for the run with end date) - const dateEntries = within(datesRow).getAllByTestId("date-entry") - const dateEntriesWithEnd = dateEntries.filter((entry) => - entry.textContent?.includes("End:"), - ) - expect(dateEntriesWithEnd).toHaveLength(1) + const datesRow = screen.getByTestId(TestIds.DatesRow) - // Should show the end date that exists - invariant(runWithEndDate.end_date) - expect(datesRow).toHaveTextContent(formatDate(runWithEndDate.end_date)) - }) + // Click "More Dates" + const moreDatesButton = within(datesRow).getByRole("button", { + name: "More Dates", + }) + await user.click(moreDatesButton) + + // Both start dates should be visible + invariant(runWithEndDate.start_date) + invariant(runWithoutEndDate.start_date) + expect(datesRow).toHaveTextContent(formatDate(runWithEndDate.start_date)) + expect(datesRow).toHaveTextContent( + formatDate(runWithoutEndDate.start_date), + ) - test("Shows 'Start: Anytime' for self-paced runs with past start dates when multiple dates exist", async () => { - // Run that should show "Anytime" (self-paced, not archived, past start date) - const anytimeRun = makeRun({ - is_enrollable: true, - is_self_paced: true, - is_archived: false, - start_date: "2025-01-01", // Past date - end_date: "2026-12-01", - }) + // Should only show one "End" label (for the run with end date) + const dateEntries = within(datesRow).getAllByTestId("date-entry") + const dateEntriesWithEnd = dateEntries.filter((entry) => + entry.textContent?.includes("End:"), + ) + expect(dateEntriesWithEnd).toHaveLength(1) - // Run that should show actual date (not self-paced) - const instructorPacedRun = makeRun({ - is_enrollable: true, - is_self_paced: false, - is_archived: false, - start_date: "2026-06-01", - end_date: "2026-08-01", + // Should show the end date that exists + invariant(runWithEndDate.end_date) + expect(datesRow).toHaveTextContent(formatDate(runWithEndDate.end_date)) }) - // Run that should show actual date (self-paced but future start date) - const futureRun = makeRun({ - is_enrollable: true, - is_self_paced: true, - is_archived: false, - start_date: "2026-09-01", // Future date - end_date: "2026-11-01", - }) + test("Shows 'Start: Anytime' for self-paced runs with past start dates when multiple dates exist", async () => { + // Run that should show "Anytime" (self-paced, not archived, past start date) + const anytimeRun = makeRun({ + is_enrollable: true, + is_self_paced: true, + is_archived: false, + start_date: "2025-01-01", // Past date + end_date: "2026-12-01", + }) - const course = makeCourse({ - next_run_id: anytimeRun.id, - courseruns: [anytimeRun, instructorPacedRun, futureRun], - }) - renderWithProviders() + // Run that should show actual date (not self-paced) + const instructorPacedRun = makeRun({ + is_enrollable: true, + is_self_paced: false, + is_archived: false, + start_date: "2026-06-01", + end_date: "2026-08-01", + }) - const summary = screen.getByRole("region", { name: "Course summary" }) - const datesRow = within(summary).getByTestId(TestIds.DatesRow) + // Run that should show actual date (self-paced but future start date) + const futureRun = makeRun({ + is_enrollable: true, + is_self_paced: true, + is_archived: false, + start_date: "2026-09-01", // Future date + end_date: "2026-11-01", + }) - // Click "More Dates" to see all - const moreDatesButton = within(datesRow).getByRole("button", { - name: "More Dates", - }) - await user.click(moreDatesButton) + const course = makeCourse({ + next_run_id: anytimeRun.id, + courseruns: [anytimeRun, instructorPacedRun, futureRun], + }) + renderWithProviders() - // Should show "Anytime" for the self-paced run with past start date - expect(datesRow).toHaveTextContent("Start: Anytime") + const datesRow = screen.getByTestId(TestIds.DatesRow) - // Should show actual dates for the other runs - invariant(instructorPacedRun.start_date) - invariant(futureRun.start_date) - expect(datesRow).toHaveTextContent( - formatDate(instructorPacedRun.start_date), - ) - expect(datesRow).toHaveTextContent(formatDate(futureRun.start_date)) - - // All should show end dates - invariant(anytimeRun.end_date) - invariant(instructorPacedRun.end_date) - invariant(futureRun.end_date) - expect(datesRow).toHaveTextContent(formatDate(anytimeRun.end_date)) - expect(datesRow).toHaveTextContent(formatDate(instructorPacedRun.end_date)) - expect(datesRow).toHaveTextContent(formatDate(futureRun.end_date)) - }) + // Click "More Dates" to see all + const moreDatesButton = within(datesRow).getByRole("button", { + name: "More Dates", + }) + await user.click(moreDatesButton) - test("Archived runs show actual dates even if they meet anytime criteria", async () => { - // Archived run that would show "Anytime" if not archived - const archivedRun = makeRun({ - is_enrollable: true, - is_self_paced: true, - is_archived: true, // Archived! - start_date: "2025-01-01", // Past date - end_date: "2025-12-01", - }) + // Should show "Anytime" for the self-paced run with past start date + expect(datesRow).toHaveTextContent("Start: Anytime") - // Active anytime run for comparison - const anytimeRun = makeRun({ - is_enrollable: true, - is_self_paced: true, - is_archived: false, - start_date: "2025-06-01", // Past date - end_date: "2026-12-01", + // Should show actual dates for the other runs + invariant(instructorPacedRun.start_date) + invariant(futureRun.start_date) + expect(datesRow).toHaveTextContent( + formatDate(instructorPacedRun.start_date), + ) + expect(datesRow).toHaveTextContent(formatDate(futureRun.start_date)) + + // All should show end dates + invariant(anytimeRun.end_date) + invariant(instructorPacedRun.end_date) + invariant(futureRun.end_date) + expect(datesRow).toHaveTextContent(formatDate(anytimeRun.end_date)) + expect(datesRow).toHaveTextContent( + formatDate(instructorPacedRun.end_date), + ) + expect(datesRow).toHaveTextContent(formatDate(futureRun.end_date)) }) - const course = makeCourse({ - next_run_id: archivedRun.id, - courseruns: [archivedRun, anytimeRun], - }) - renderWithProviders() + test("Archived runs show actual dates even if they meet anytime criteria", async () => { + // Archived run that would show "Anytime" if not archived + const archivedRun = makeRun({ + is_enrollable: true, + is_self_paced: true, + is_archived: true, // Archived! + start_date: "2025-01-01", // Past date + end_date: "2025-12-01", + }) + + // Active anytime run for comparison + const anytimeRun = makeRun({ + is_enrollable: true, + is_self_paced: true, + is_archived: false, + start_date: "2025-06-01", // Past date + end_date: "2026-12-01", + }) + + const course = makeCourse({ + next_run_id: archivedRun.id, + courseruns: [archivedRun, anytimeRun], + }) + renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const datesRow = within(summary).getByTestId(TestIds.DatesRow) + const datesRow = screen.getByTestId(TestIds.DatesRow) - // Click "More Dates" to see all - const moreDatesButton = within(datesRow).getByRole("button", { - name: "More Dates", - }) - await user.click(moreDatesButton) + // Click "More Dates" to see all + const moreDatesButton = within(datesRow).getByRole("button", { + name: "More Dates", + }) + await user.click(moreDatesButton) - // Active run should show "Anytime" - expect(datesRow).toHaveTextContent("Start: Anytime") + // Active run should show "Anytime" + expect(datesRow).toHaveTextContent("Start: Anytime") - // Archived run should show actual date, not "Anytime" - invariant(archivedRun.start_date) - expect(datesRow).toHaveTextContent(formatDate(archivedRun.start_date)) + // Archived run should show actual date, not "Anytime" + invariant(archivedRun.start_date) + expect(datesRow).toHaveTextContent(formatDate(archivedRun.start_date)) - // Both should show end dates - invariant(archivedRun.end_date) - invariant(anytimeRun.end_date) - expect(datesRow).toHaveTextContent(formatDate(archivedRun.end_date)) - expect(datesRow).toHaveTextContent(formatDate(anytimeRun.end_date)) + // Both should show end dates + invariant(archivedRun.end_date) + invariant(anytimeRun.end_date) + expect(datesRow).toHaveTextContent(formatDate(archivedRun.end_date)) + expect(datesRow).toHaveTextContent(formatDate(anytimeRun.end_date)) + }) }) -}) -describe("Course Format Row", () => { - test.each([ - { - descrip: "self-paced", - overrides: { is_self_paced: true, is_archived: false }, - }, - { - descrip: "archived", - overrides: { is_archived: true }, - }, - ])( - "Renders self-paced for $descrip courses with appropriate dialog", - async ({ overrides }) => { - const run = makeRun(overrides) + describe("Format Row", () => { + test.each([ + { + descrip: "self-paced", + overrides: { is_self_paced: true, is_archived: false }, + }, + { + descrip: "archived", + overrides: { is_archived: true }, + }, + ])( + "Renders self-paced for $descrip courses with appropriate dialog", + async ({ overrides }) => { + const run = makeRun(overrides) + const course = makeCourse({ + availability: "dated", + next_run_id: run.id, + courseruns: shuffle([run, makeRun()]), + }) + renderWithProviders() + + const formatRow = screen.getByTestId(TestIds.PaceRow) + expect(formatRow).toHaveTextContent("Course Format: Self-Paced") + + const dialogTitle = "What are Self-Paced courses?" + const button = within(formatRow).getByRole("button", { + name: dialogTitle, + }) + await user.click(button) + const dialog = await screen.findByRole("dialog", { + name: dialogTitle, + }) + + await user.click(within(dialog).getByRole("button", { name: "Close" })) + + expect(dialog).not.toBeVisible() + }, + ) + + test("Renders instructor-led for non-self-paced courses", async () => { + const run = makeRun({ is_self_paced: false, is_archived: false }) const course = makeCourse({ availability: "dated", next_run_id: run.id, @@ -702,179 +690,217 @@ describe("Course Format Row", () => { }) renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const formatRow = within(summary).getByTestId(TestIds.PaceRow) - expect(formatRow).toHaveTextContent("Course Format: Self-Paced") + const formatRow = screen.getByTestId(TestIds.PaceRow) + expect(formatRow).toHaveTextContent("Course Format: Instructor-Paced") + const dialogTitle = "What are Instructor-Paced courses?" const button = within(formatRow).getByRole("button", { - name: "What's this?", + name: dialogTitle, }) + await user.click(button) const dialog = await screen.findByRole("dialog", { - name: "What are Self-Paced courses?", + name: dialogTitle, }) await user.click(within(dialog).getByRole("button", { name: "Close" })) expect(dialog).not.toBeVisible() - }, - ) - - test("Renders instructor-led for non-self-paced courses", async () => { - const run = makeRun({ is_self_paced: false, is_archived: false }) - const course = makeCourse({ - availability: "dated", - next_run_id: run.id, - courseruns: shuffle([run, makeRun()]), }) - renderWithProviders() + }) - const summary = screen.getByRole("region", { name: "Course summary" }) - const formatRow = within(summary).getByTestId(TestIds.PaceRow) - expect(formatRow).toHaveTextContent("Course Format: Instructor-Paced") + describe("Duration Row", () => { + test.each([ + { + length: "5 weeks", + effort: "3-5 hours per week", + expected: "5 weeks, 3-5 hours per week", + }, + { length: "6 weeks", effort: null, expected: "6 weeks" }, + { length: "", expected: null }, + ])( + "Renders expected duration in weeks and effort", + ({ length, effort, expected }) => { + const course = makeCourse({ + page: { length, effort }, + }) - const button = within(formatRow).getByRole("button", { - name: "What's this?", - }) + renderWithProviders() - await user.click(button) - const dialog = await screen.findByRole("dialog", { - name: "What are Instructor-Paced courses?", - }) + if (!length) { + expect(screen.queryByTestId(TestIds.DurationRow)).toBeNull() + } else { + const durationRow = screen.getByTestId(TestIds.DurationRow) + expect(durationRow).toHaveTextContent(`Estimated: ${expected}`) + } + }, + ) + + test("Duration row displays even when there is no next run", () => { + const course = makeCourse({ + next_run_id: null, + courseruns: [], + page: { + length: "5 weeks", + effort: "10 hours/week", + }, + }) + renderWithProviders() - await user.click(within(dialog).getByRole("button", { name: "Close" })) + const durationRow = screen.getByTestId(TestIds.DurationRow) - expect(dialog).not.toBeVisible() + expect(durationRow).toBeInTheDocument() + expect(durationRow).toHaveTextContent("Estimated: 5 weeks, 10 hours/week") + }) }) -}) -describe("Course Duration Row", () => { - test.each([ - { - length: "5 weeks", - effort: "3-5 hours per week", - expected: "5 weeks, 3-5 hours per week", - }, - { length: "6 weeks", effort: null, expected: "6 weeks" }, - { length: "", expected: null }, - ])( - "Renders expected duration in weeks and effort", - ({ length, effort, expected }) => { + describe("Price Row", () => { + test.each([ + { + label: "has no products", + runOverrides: { is_archived: false, products: [] }, + }, + { + label: "is archived", + runOverrides: { is_archived: true, products: [makeProduct()] }, + }, + ])("Does not offer certificate if $label", ({ runOverrides }) => { + const run = makeRun(runOverrides) const course = makeCourse({ - page: { length, effort }, + next_run_id: run.id, + courseruns: shuffle([run, makeRun()]), }) - renderWithProviders() + const priceRow = screen.getByTestId(TestIds.PriceRow) - const summary = screen.getByRole("region", { name: "Course summary" }) - - if (!length) { - expect(within(summary).queryByTestId(TestIds.DurationRow)).toBeNull() - } else { - const durationRow = within(summary).getByTestId(TestIds.DurationRow) - expect(durationRow).toHaveTextContent(`Estimated: ${expected}`) - } - }, - ) + expect(priceRow).not.toHaveTextContent("Payment deadline") + expect(priceRow).toHaveTextContent("Certificate deadline passed") - test("Duration row displays even when there is no next run", () => { - const course = makeCourse({ - next_run_id: null, - courseruns: [], - page: { - length: "5 weeks", - effort: "10 hours/week", - }, + expect(priceRow).toHaveTextContent("Free to Learn") }) - renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const durationRow = within(summary).getByTestId(TestIds.DurationRow) - - expect(durationRow).toBeInTheDocument() - expect(durationRow).toHaveTextContent("Estimated: 5 weeks, 10 hours/week") - }) -}) + test("Offers certificate upgrade if not archived and has product", () => { + const run = makeRun({ + is_archived: false, + products: [makeProduct()], + is_enrollable: true, + is_upgradable: true, + }) + const course = makeCourse({ + next_run_id: run.id, + courseruns: shuffle([run, makeRun()]), + }) + renderWithProviders() + const priceRow = screen.getByTestId(TestIds.PriceRow) -describe("Course Price Row", () => { - test.each([ - { - label: "has no products", - runOverrides: { is_archived: false, products: [] }, - }, - { - label: "is archived", - runOverrides: { is_archived: true, products: [makeProduct()] }, - }, - ])("Does not offer certificate if $label", ({ runOverrides }) => { - const run = makeRun(runOverrides) - const course = makeCourse({ - next_run_id: run.id, - courseruns: shuffle([run, makeRun()]), + expect(priceRow).toHaveTextContent( + `Earn a certificate: $${run.products[0].price}`, + ) + invariant(run.upgrade_deadline) + expect(priceRow).toHaveTextContent( + `Payment deadline: ${formatDate(run.upgrade_deadline)}`, + ) + expect(priceRow).not.toHaveTextContent("Certificate deadline passed") }) - renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const priceRow = within(summary).getByTestId(TestIds.PriceRow) - expect(priceRow).not.toHaveTextContent("Payment deadline") - expect(priceRow).toHaveTextContent("Certificate deadline passed") + test("Price row displays with 'Certificate deadline passed' when no next run is found", () => { + const course = makeCourse({ + next_run_id: null, + courseruns: [], + }) + renderWithProviders() - expect(priceRow).toHaveTextContent("Free to Learn") - }) + const priceRow = screen.getByTestId(TestIds.PriceRow) - test("Offers certificate upgrade if not archived and has product", () => { - const run = makeRun({ - is_archived: false, - products: [makeProduct()], - is_enrollable: true, - is_upgradable: true, + expect(priceRow).toBeInTheDocument() + expect(priceRow).toHaveTextContent("Free to Learn") + expect(priceRow).toHaveTextContent("Certificate deadline passed") + expect(priceRow).not.toHaveTextContent("Payment deadline") }) - const course = makeCourse({ - next_run_id: run.id, - courseruns: shuffle([run, makeRun()]), - }) - renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const priceRow = within(summary).getByTestId(TestIds.PriceRow) - - expect(priceRow).toHaveTextContent( - `Certificate Track: $${run.products[0].price}`, - ) - invariant(run.upgrade_deadline) - expect(priceRow).toHaveTextContent( - `Payment deadline: ${formatDate(run.upgrade_deadline)}`, - ) - expect(priceRow).not.toHaveTextContent("Certificate deadline passed") }) - test("Price row displays with 'Certificate deadline passed' when no next run is found", () => { - const course = makeCourse({ - next_run_id: null, - courseruns: [], - }) - renderWithProviders() - - const summary = screen.getByRole("region", { name: "Course summary" }) - const priceRow = within(summary).getByTestId(TestIds.PriceRow) + describe("Financial Assistance", () => { + test.each([ + { hasFinancialAid: true, expectLink: true }, + { hasFinancialAid: false, expectLink: false }, + ])( + "Financial aid link is displayed if and only if URL is non-empty (hasFinancialAid=$hasFinancialAid)", + async ({ hasFinancialAid, expectLink }) => { + const financialAidUrl = hasFinancialAid + ? `/financial-aid/${faker.string.alphanumeric(10)}` + : "" + const product = makeProduct() + const run = makeRun({ + is_archived: false, + products: [product], + is_enrollable: true, + is_upgradable: true, + }) + const course = makeCourse({ + next_run_id: run.id, + courseruns: [run], + page: { financial_assistance_form_url: financialAidUrl }, + }) - expect(priceRow).toBeInTheDocument() - expect(priceRow).toHaveTextContent("Free to Learn") - expect(priceRow).toHaveTextContent("Certificate deadline passed") - expect(priceRow).not.toHaveTextContent("Payment deadline") - }) -}) + // Mock the flexible price API response when financial aid is available + if (hasFinancialAid) { + const mockFlexiblePrice = makeFlexiblePrice({ + id: product.id, + price: product.price, + product_flexible_price: null, + }) + setMockResponse.get( + urls.products.userFlexiblePriceDetail(product.id), + mockFlexiblePrice, + ) + } + + renderWithProviders() + + const priceRow = screen.getByTestId(TestIds.PriceRow) + + if (expectLink) { + const link = await within(priceRow).findByRole("link", { + name: /financial assistance/i, + }) + const expectedUrl = new URL( + financialAidUrl, + process.env.NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL, + ).toString() + expect(link).toHaveAttribute("href", expectedUrl) + expect(link).toHaveTextContent("Financial assistance available") + } else { + const link = within(priceRow).queryByRole("link", { + name: /financial assistance/i, + }) + expect(link).toBeNull() + expect(link).toBeNull() + } + }, + ) -describe("Course Financial Assistance", () => { - test.each([ - { hasFinancialAid: true, expectLink: true }, - { hasFinancialAid: false, expectLink: false }, - ])( - "Financial aid link is displayed if and only if URL is non-empty (hasFinancialAid=$hasFinancialAid)", - async ({ hasFinancialAid, expectLink }) => { - const financialAidUrl = hasFinancialAid - ? `/financial-aid/${faker.string.alphanumeric(10)}` - : "" - const product = makeProduct() + test("Displays user-specific discounted price when financial aid is available", async () => { + const originalPrice = "100.00" + const discountedAmount = "50.00" + const product = makeProduct({ price: originalPrice }) + const flexiblePrice = makeFlexiblePrice({ + id: product.id, + price: originalPrice, + product_flexible_price: { + id: faker.number.int(), + amount: discountedAmount, + discount_type: "dollars-off" as const, + discount_code: faker.string.alphanumeric(8), + redemption_type: "one-time" as const, + is_redeemed: false, + automatic: true, + max_redemptions: 1, + payment_type: null, + activation_date: faker.date.past().toISOString(), + expiration_date: faker.date.future().toISOString(), + }, + }) + const financialAidUrl = `/financial-aid/${faker.string.alphanumeric(10)}` const run = makeRun({ is_archived: false, products: [product], @@ -887,540 +913,448 @@ describe("Course Financial Assistance", () => { page: { financial_assistance_form_url: financialAidUrl }, }) - // Mock the flexible price API response when financial aid is available - if (hasFinancialAid) { - const mockFlexiblePrice = makeFlexiblePrice({ - id: product.id, - price: product.price, - product_flexible_price: null, - }) - setMockResponse.get( - urls.products.userFlexiblePriceDetail(product.id), - mockFlexiblePrice, - ) - } + setMockResponse.get( + urls.products.userFlexiblePriceDetail(product.id), + flexiblePrice, + ) renderWithProviders() - const summary = await screen.findByRole("region", { - name: "Course summary", - }) - const priceRow = within(summary).getByTestId(TestIds.PriceRow) - - if (expectLink) { - const link = await within(priceRow).findByRole("link", { - name: /financial assistance/i, - }) - const expectedUrl = new URL( - financialAidUrl, - process.env.NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL, - ).toString() - expect(link).toHaveAttribute("href", expectedUrl) - expect(link).toHaveTextContent("Financial assistance available") - } else { - const link = within(priceRow).queryByRole("link", { - name: /financial assistance/i, - }) - expect(link).toBeNull() - expect(link).toBeNull() - } - }, - ) - - test("Displays user-specific discounted price when financial aid is available", async () => { - const originalPrice = "100.00" - const discountedAmount = "50.00" - const product = makeProduct({ price: originalPrice }) - const flexiblePrice = makeFlexiblePrice({ - id: product.id, - price: originalPrice, - product_flexible_price: { - id: faker.number.int(), - amount: discountedAmount, - discount_type: "dollars-off" as const, - discount_code: faker.string.alphanumeric(8), - redemption_type: "one-time" as const, - is_redeemed: false, - automatic: true, - max_redemptions: 1, - payment_type: null, - activation_date: faker.date.past().toISOString(), - expiration_date: faker.date.future().toISOString(), - }, - }) - const financialAidUrl = `/financial-aid/${faker.string.alphanumeric(10)}` - const run = makeRun({ - is_archived: false, - products: [product], - is_enrollable: true, - is_upgradable: true, - }) - const course = makeCourse({ - next_run_id: run.id, - courseruns: [run], - page: { financial_assistance_form_url: financialAidUrl }, - }) - - setMockResponse.get( - urls.products.userFlexiblePriceDetail(product.id), - flexiblePrice, - ) - - renderWithProviders() - - const summary = await screen.findByRole("region", { - name: "Course summary", - }) - const priceRow = within(summary).getByTestId(TestIds.PriceRow) - - // Wait for the flexible price API to be called and prices to be displayed - // The discounted price is calculated as: $100 - $50 = $50 - await within(priceRow).findByText("Financial assistance applied") - expect(priceRow).toHaveTextContent("$50.00") - expect(priceRow).toHaveTextContent("$100.00") - }) + const priceRow = screen.getByTestId(TestIds.PriceRow) - test("Does NOT call flexible price API when financial aid URL is empty", () => { - const product = makeProduct({ price: "100.00" }) - const run = makeRun({ - is_archived: false, - products: [product], - is_enrollable: true, - is_upgradable: true, - }) - const course = makeCourse({ - next_run_id: run.id, - courseruns: [run], - page: { financial_assistance_form_url: "" }, + // Wait for the flexible price API to be called and prices to be displayed + // The discounted price is calculated as: $100 - $50 = $50 + await within(priceRow).findByText("Financial assistance applied") + expect(priceRow).toHaveTextContent("$50.00") + expect(priceRow).toHaveTextContent("$100.00") }) - // We're NOT setting up a mock response for the flexible price API - // If it's called, the test will fail + test("Does NOT call flexible price API when financial aid URL is empty", () => { + const product = makeProduct({ price: "100.00" }) + const run = makeRun({ + is_archived: false, + products: [product], + is_enrollable: true, + is_upgradable: true, + }) + const course = makeCourse({ + next_run_id: run.id, + courseruns: [run], + page: { financial_assistance_form_url: "" }, + }) - renderWithProviders() + // We're NOT setting up a mock response for the flexible price API + // If it's called, the test will fail - const summary = screen.getByRole("region", { name: "Course summary" }) - const priceRow = within(summary).getByTestId(TestIds.PriceRow) + renderWithProviders() - // Should show the regular price - expect(priceRow).toHaveTextContent(`$${product.price}`) - // Should NOT show financial assistance link - expect( - within(priceRow).queryByRole("link", { name: /financial assistance/i }), - ).toBeNull() - }) + const priceRow = screen.getByTestId(TestIds.PriceRow) - test("Does NOT show financial assistance when certificate link is present but products array is empty", () => { - const financialAidUrl = `/financial-aid/${faker.string.alphanumeric(10)}` - const run = makeRun({ - is_archived: false, - products: [], - is_enrollable: true, - is_upgradable: false, - }) - const course = makeCourse({ - next_run_id: run.id, - courseruns: [run], - page: { financial_assistance_form_url: financialAidUrl }, + // Should show the regular price + expect(priceRow).toHaveTextContent(`$${product.price}`) + // Should NOT show financial assistance link + expect( + within(priceRow).queryByRole("link", { name: /financial assistance/i }), + ).toBeNull() }) - renderWithProviders() + test("Does NOT show financial assistance when certificate link is present but products array is empty", () => { + const financialAidUrl = `/financial-aid/${faker.string.alphanumeric(10)}` + const run = makeRun({ + is_archived: false, + products: [], + is_enrollable: true, + is_upgradable: false, + }) + const course = makeCourse({ + next_run_id: run.id, + courseruns: [run], + page: { financial_assistance_form_url: financialAidUrl }, + }) - const summary = screen.getByRole("region", { name: "Course summary" }) - const priceRow = within(summary).getByTestId(TestIds.PriceRow) + renderWithProviders() - // Should show "Certificate deadline passed" since no products - expect(priceRow).toHaveTextContent("Certificate deadline passed") + const priceRow = screen.getByTestId(TestIds.PriceRow) - // Certificate link should be present - const certLink = within(priceRow).getByRole("link", { - name: /Learn More/i, - }) - expect(certLink).toBeInTheDocument() + // Should show "Certificate deadline passed" since no products + expect(priceRow).toHaveTextContent("Certificate deadline passed") - // Financial assistance link should NOT be present - expect( - within(priceRow).queryByRole("link", { name: /financial assistance/i }), - ).toBeNull() - }) -}) + // Certificate link should be present + const certLink = within(priceRow).getByRole("link", { + name: /Learn More/i, + }) + expect(certLink).toBeInTheDocument() -describe("Course In Programs Row", () => { - test("Does not render when programs array is null", () => { - const run = makeRun() - const course = makeCourse({ - next_run_id: run.id, - courseruns: [run], - programs: null, + // Financial assistance link should NOT be present + expect( + within(priceRow).queryByRole("link", { name: /financial assistance/i }), + ).toBeNull() }) - renderWithProviders() - - const summary = screen.getByRole("region", { name: "Course summary" }) - const programsRow = within(summary).queryByTestId( - TestIds.CourseInProgramsRow, - ) - expect(programsRow).toBeNull() }) - test("Does not render when programs array is empty", () => { - const run = makeRun() - const course = makeCourse({ - next_run_id: run.id, - courseruns: [run], - programs: [], - }) - renderWithProviders() - - const summary = screen.getByRole("region", { name: "Course summary" }) - const programsRow = within(summary).queryByTestId( - TestIds.CourseInProgramsRow, - ) - expect(programsRow).toBeNull() - }) + describe("In Programs Row", () => { + test("Does not render when programs array is null", () => { + const run = makeRun() + const course = makeCourse({ + next_run_id: run.id, + courseruns: [run], + programs: null, + }) + renderWithProviders() - test("Renders link to one program", () => { - const program = { - id: 1, - readable_id: "program-1", - title: "Test Program 1", - type: "program", - } - const run = makeRun() - const course = makeCourse({ - next_run_id: run.id, - courseruns: [run], - programs: [program], + const programsRow = screen.queryByTestId(TestIds.CourseInProgramsRow) + expect(programsRow).toBeNull() }) - renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const programsRow = within(summary).getByTestId(TestIds.CourseInProgramsRow) + test("Does not render when programs array is empty", () => { + const run = makeRun() + const course = makeCourse({ + next_run_id: run.id, + courseruns: [run], + programs: [], + }) + renderWithProviders() - expect(programsRow).toHaveTextContent("Part of the following program") - const link = within(programsRow).getByRole("link", { - name: "Test Program 1", + const programsRow = screen.queryByTestId(TestIds.CourseInProgramsRow) + expect(programsRow).toBeNull() }) - expect(link).toHaveAttribute("href", "/programs/program-1") - }) - test("Renders links to multiple programs", () => { - const programs = [ - { + test("Renders link to one program", () => { + const program = { id: 1, readable_id: "program-1", title: "Test Program 1", type: "program", - }, - { - id: 2, - readable_id: "program-2", - title: "Test Program 2", - type: "program", - }, - { - id: 3, - readable_id: "program-3", - title: "Test Program 3", - type: "program", - }, - ] - const run = makeRun() - const course = makeCourse({ - next_run_id: run.id, - courseruns: [run], - programs: programs, + } + const run = makeRun() + const course = makeCourse({ + next_run_id: run.id, + courseruns: [run], + programs: [program], + }) + renderWithProviders() + + const programsRow = screen.getByTestId(TestIds.CourseInProgramsRow) + + expect(programsRow).toHaveTextContent("Part of the following program") + const link = within(programsRow).getByRole("link", { + name: "Test Program 1", + }) + expect(link).toHaveAttribute("href", "/programs/program-1") }) - renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const programsRow = within(summary).getByTestId(TestIds.CourseInProgramsRow) + test("Renders links to multiple programs", () => { + const programs = [ + { + id: 1, + readable_id: "program-1", + title: "Test Program 1", + type: "program", + }, + { + id: 2, + readable_id: "program-2", + title: "Test Program 2", + type: "program", + }, + { + id: 3, + readable_id: "program-3", + title: "Test Program 3", + type: "program", + }, + ] + const run = makeRun() + const course = makeCourse({ + next_run_id: run.id, + courseruns: [run], + programs: programs, + }) + renderWithProviders() - expect(programsRow).toHaveTextContent("Part of the following programs") + const programsRow = screen.getByTestId(TestIds.CourseInProgramsRow) - const link1 = within(programsRow).getByRole("link", { - name: "Test Program 1", - }) - expect(link1).toHaveAttribute("href", "/programs/program-1") + expect(programsRow).toHaveTextContent("Part of the following programs") - const link2 = within(programsRow).getByRole("link", { - name: "Test Program 2", - }) - expect(link2).toHaveAttribute("href", "/programs/program-2") + const link1 = within(programsRow).getByRole("link", { + name: "Test Program 1", + }) + expect(link1).toHaveAttribute("href", "/programs/program-1") - const link3 = within(programsRow).getByRole("link", { - name: "Test Program 3", - }) - expect(link3).toHaveAttribute("href", "/programs/program-3") - }) + const link2 = within(programsRow).getByRole("link", { + name: "Test Program 2", + }) + expect(link2).toHaveAttribute("href", "/programs/program-2") - test("Displays programs row even when no next run is found", () => { - const program = { - id: 1, - readable_id: "program-1", - title: "Test Program 1", - type: "program", - } - const course = makeCourse({ - next_run_id: null, - courseruns: [], - programs: [program], + const link3 = within(programsRow).getByRole("link", { + name: "Test Program 3", + }) + expect(link3).toHaveAttribute("href", "/programs/program-3") }) - renderWithProviders() - const summary = screen.getByRole("region", { name: "Course summary" }) - const programsRow = within(summary).getByTestId(TestIds.CourseInProgramsRow) + test("Displays programs row even when no next run is found", () => { + const program = { + id: 1, + readable_id: "program-1", + title: "Test Program 1", + type: "program", + } + const course = makeCourse({ + next_run_id: null, + courseruns: [], + programs: [program], + }) + renderWithProviders() + + const programsRow = screen.getByTestId(TestIds.CourseInProgramsRow) - expect(programsRow).toBeInTheDocument() - expect(programsRow).toHaveTextContent("Part of the following program") - const link = within(programsRow).getByRole("link", { - name: "Test Program 1", + expect(programsRow).toBeInTheDocument() + expect(programsRow).toHaveTextContent("Part of the following program") + const link = within(programsRow).getByRole("link", { + name: "Test Program 1", + }) + expect(link).toHaveAttribute("href", "/programs/program-1") }) - expect(link).toHaveAttribute("href", "/programs/program-1") }) }) describe("ProgramSummary", () => { - test("renders program summary", async () => { + test("renders program summary rows", async () => { const program = factories.programs.program() renderWithProviders() - const summary = screen.getByRole("region", { name: "Program summary" }) - within(summary).getByRole("heading", { name: "Program summary" }) + screen.getByTestId(TestIds.PriceRow) }) -}) -describe("Program RequirementsRow", () => { - test("Renders requirement count with required + elective courses", () => { - const requirements = new RequirementTreeBuilder() - const required = requirements.addOperator({ operator: "all_of" }) - required.addCourse() - required.addCourse() - required.addCourse() - const electives = requirements.addOperator({ - operator: "min_number_of", - operator_value: "2", - }) - electives.addCourse() - electives.addCourse() - electives.addCourse() - electives.addCourse() - - const program = factories.programs.program({ - requirements: { - courses: { - required: - required.children?.map((n) => ({ - id: n.id, - readable_id: `readale-${n.id}`, - })) ?? [], - electives: - electives.children?.map((n) => ({ - id: n.id, - readable_id: `readale-${n.id}`, - })) ?? [], - }, - programs: { required: [], electives: [] }, - }, - req_tree: requirements.serialize(), - }) + describe("RequirementsRow", () => { + test("Renders requirement count with required + elective courses", () => { + const requirements = new RequirementTreeBuilder() + const required = requirements.addOperator({ operator: "all_of" }) + required.addCourse() + required.addCourse() + required.addCourse() + const electives = requirements.addOperator({ + operator: "min_number_of", + operator_value: "2", + }) + electives.addCourse() + electives.addCourse() + electives.addCourse() + electives.addCourse() - renderWithProviders() + const program = factories.programs.program({ + requirements: { + courses: { + required: + required.children?.map((n) => ({ + id: n.id, + readable_id: `readale-${n.id}`, + })) ?? [], + electives: + electives.children?.map((n) => ({ + id: n.id, + readable_id: `readale-${n.id}`, + })) ?? [], + }, + programs: { required: [], electives: [] }, + }, + req_tree: requirements.serialize(), + }) - const summary = screen.getByRole("region", { name: "Program summary" }) - const reqRow = within(summary).getByTestId(TestIds.RequirementsRow) + renderWithProviders() - // The text is split more nicely on screen, but via html tags not spaces - expect(reqRow).toHaveTextContent("5 Courses to complete program") - }) + const reqRow = screen.getByTestId(TestIds.RequirementsRow) - test("Renders requirement count correctly if no electives", () => { - const requirements = new RequirementTreeBuilder() - const required = requirements.addOperator({ operator: "all_of" }) - required.addCourse() - required.addCourse() - required.addCourse() - - const program = factories.programs.program({ - requirements: { - courses: { - required: - required.children?.map((n) => ({ - id: n.id, - readable_id: `readale-${n.id}`, - })) ?? [], - electives: [], - }, - programs: { required: [], electives: [] }, - }, - req_tree: requirements.serialize(), + // The text is split more nicely on screen, but via html tags not spaces + expect(reqRow).toHaveTextContent("5 Courses to complete program") }) - renderWithProviders() - - const summary = screen.getByRole("region", { name: "Program summary" }) - const reqRow = within(summary).getByTestId(TestIds.RequirementsRow) - - expect(reqRow).toHaveTextContent("3 Courses to complete program") - }) -}) + test("Renders requirement count correctly if no electives", () => { + const requirements = new RequirementTreeBuilder() + const required = requirements.addOperator({ operator: "all_of" }) + required.addCourse() + required.addCourse() + required.addCourse() -describe("Program Duration Row", () => { - test.each([ - { - length: "5 weeks", - effort: "3-5 hours per week", - expected: "5 weeks, 3-5 hours per week", - }, - { length: "6 weeks", effort: null, expected: "6 weeks" }, - { length: "", expected: null }, - ])( - "Renders expected duration in weeks and effort", - ({ length, effort, expected }) => { const program = factories.programs.program({ - page: { length, effort }, + requirements: { + courses: { + required: + required.children?.map((n) => ({ + id: n.id, + readable_id: `readale-${n.id}`, + })) ?? [], + electives: [], + }, + programs: { required: [], electives: [] }, + }, + req_tree: requirements.serialize(), }) renderWithProviders() - const summary = screen.getByRole("region", { name: "Program summary" }) - if (!length) { - expect(within(summary).queryByTestId(TestIds.DurationRow)).toBeNull() - } else { - const durationRow = within(summary).getByTestId(TestIds.DurationRow) - expect(durationRow).toHaveTextContent(`Estimated: ${expected}`) - } - }, - ) -}) - -describe("Program Pacing Row", () => { - const courses = { - selfPaced: makeCourse({ - courseruns: [makeRun({ is_self_paced: true })], - }), - instructorPaced: makeCourse({ - courseruns: [makeRun({ is_self_paced: false, is_archived: false })], - }), - archived: makeCourse({ - // counts as self-paced. - courseruns: [makeRun({ is_self_paced: false, is_archived: true })], - }), - noRuns: makeCourse({ - courseruns: [], - }), - } + const reqRow = screen.getByTestId(TestIds.RequirementsRow) - test.each([ - { courses: [courses.selfPaced], expected: "Self-Paced" }, - { courses: [courses.archived], expected: "Self-Paced" }, - { courses: [courses.instructorPaced], expected: "Instructor-Paced" }, - { - courses: [courses.selfPaced, courses.instructorPaced], - expected: "Instructor-Paced", - }, - { - courses: [courses.noRuns, courses.instructorPaced], - expected: "Instructor-Paced", - }, - { - courses: [courses.noRuns, courses.selfPaced], - expected: "Self-Paced", - }, - ])("Shows correct pacing information", ({ courses, expected }) => { - const program = factories.programs.program() - renderWithProviders() - const summary = screen.getByRole("region", { name: "Program summary" }) - const paceRow = within(summary).getByTestId(TestIds.PaceRow) - expect(paceRow).toHaveTextContent(`Course Format: ${expected}`) + expect(reqRow).toHaveTextContent("3 Courses to complete program") + }) }) - test.each([ - { courses: [courses.selfPaced], dialogName: /What are Self-Paced/ }, - { - courses: [courses.instructorPaced], - dialogName: /What are Instructor-Paced/, - }, - { - courses: [courses.selfPaced, courses.instructorPaced], - dialogName: /What are Instructor-Paced/, - }, - { - courses: [courses.archived], - dialogName: /What are Self-Paced/, - }, - ])("Renders expected dialog", async ({ courses, dialogName }) => { - const program = factories.programs.program() - renderWithProviders() - const summary = screen.getByRole("region", { name: "Program summary" }) - const paceRow = within(summary).getByTestId(TestIds.PaceRow) - const button = within(paceRow).getByRole("button", { name: "What's this?" }) - await user.click(button) - const dialog = await screen.findByRole("dialog", { - name: dialogName, - }) + describe("Duration Row", () => { + test.each([ + { + length: "5 weeks", + effort: "3-5 hours per week", + expected: "5 weeks, 3-5 hours per week", + }, + { length: "6 weeks", effort: null, expected: "6 weeks" }, + { length: "", expected: null }, + ])( + "Renders expected duration in weeks and effort", + ({ length, effort, expected }) => { + const program = factories.programs.program({ + page: { length, effort }, + }) - await user.click(within(dialog).getByRole("button", { name: "Close" })) + renderWithProviders() - expect(dialog).not.toBeVisible() + if (!length) { + expect(screen.queryByTestId(TestIds.DurationRow)).toBeNull() + } else { + const durationRow = screen.getByTestId(TestIds.DurationRow) + expect(durationRow).toHaveTextContent(`Estimated: ${expected}`) + } + }, + ) }) -}) -describe("Price & Certificate Row", () => { - test("Shows 'Free to Learn'", () => { - const program = factories.programs.program() - renderWithProviders() + describe("Pacing Row", () => { + const courses = { + selfPaced: makeCourse({ + courseruns: [makeRun({ is_self_paced: true })], + }), + instructorPaced: makeCourse({ + courseruns: [makeRun({ is_self_paced: false, is_archived: false })], + }), + archived: makeCourse({ + // counts as self-paced. + courseruns: [makeRun({ is_self_paced: false, is_archived: true })], + }), + noRuns: makeCourse({ + courseruns: [], + }), + } - const summary = screen.getByRole("region", { name: "Program summary" }) - const priceRow = within(summary).getByTestId(TestIds.PriceRow) + test.each([ + { courses: [courses.selfPaced], expected: "Self-Paced" }, + { courses: [courses.archived], expected: "Self-Paced" }, + { courses: [courses.instructorPaced], expected: "Instructor-Paced" }, + { + courses: [courses.selfPaced, courses.instructorPaced], + expected: "Instructor-Paced", + }, + { + courses: [courses.noRuns, courses.instructorPaced], + expected: "Instructor-Paced", + }, + { + courses: [courses.noRuns, courses.selfPaced], + expected: "Self-Paced", + }, + ])("Shows correct pacing information", ({ courses, expected }) => { + const program = factories.programs.program() + renderWithProviders( + , + ) + const paceRow = screen.getByTestId(TestIds.PaceRow) + expect(paceRow).toHaveTextContent(`Course Format: ${expected}`) + }) - expect(priceRow).toHaveTextContent("Free to Learn") + test.each([ + { courses: [courses.selfPaced], dialogName: /What are Self-Paced/ }, + { + courses: [courses.instructorPaced], + dialogName: /What are Instructor-Paced/, + }, + { + courses: [courses.selfPaced, courses.instructorPaced], + dialogName: /What are Instructor-Paced/, + }, + { + courses: [courses.archived], + dialogName: /What are Self-Paced/, + }, + ])("Renders expected dialog", async ({ courses, dialogName }) => { + const program = factories.programs.program() + renderWithProviders( + , + ) + const paceRow = screen.getByTestId(TestIds.PaceRow) + const button = within(paceRow).getByRole("button", { name: dialogName }) + await user.click(button) + const dialog = await screen.findByRole("dialog", { + name: dialogName, + }) + + await user.click(within(dialog).getByRole("button", { name: "Close" })) + + expect(dialog).not.toBeVisible() + }) }) - test("Renders certificate information", () => { - const program = factories.programs.program() - invariant(program.page.price) - renderWithProviders() + describe("Price & Certificate Row", () => { + test("Shows 'Free to Learn'", () => { + const program = factories.programs.program() + renderWithProviders() - const summary = screen.getByRole("region", { name: "Program summary" }) - const certRow = within(summary).getByTestId(TestIds.PriceRow) + const priceRow = screen.getByTestId(TestIds.PriceRow) - expect(certRow).toHaveTextContent("Certificate Track") - expect(certRow).toHaveTextContent(program.page.price) - }) + expect(priceRow).toHaveTextContent("Free to Learn") + }) - test.each([ - { hasFinancialAid: true, expectLink: true }, - { hasFinancialAid: false, expectLink: false }, - ])( - "Program financial aid link is displayed if and only if URL is non-empty (hasFinancialAid=$hasFinancialAid)", - ({ hasFinancialAid, expectLink }) => { - const financialAidUrl = hasFinancialAid - ? `/financial-aid/${faker.string.alphanumeric(10)}` - : "" - const program = factories.programs.program({ - page: { financial_assistance_form_url: financialAidUrl }, - }) + test("Renders certificate information", () => { + const program = factories.programs.program() + invariant(program.page.price) renderWithProviders() - const summary = screen.getByRole("region", { name: "Program summary" }) - const priceRow = within(summary).getByTestId(TestIds.PriceRow) + const certRow = screen.getByTestId(TestIds.PriceRow) - if (expectLink) { - const link = within(priceRow).getByRole("link", { - name: /financial assistance/i, - }) - const expectedUrl = new URL( - financialAidUrl, - process.env.NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL, - ).toString() - expect(link).toHaveAttribute("href", expectedUrl) - expect(link).toHaveTextContent("Financial assistance available") - } else { - const link = within(priceRow).queryByRole("link", { - name: /financial assistance/i, + expect(certRow).toHaveTextContent("Earn a certificate") + expect(certRow).toHaveTextContent(program.page.price) + }) + + test.each([ + { hasFinancialAid: true, expectLink: true }, + { hasFinancialAid: false, expectLink: false }, + ])( + "Program financial aid link is displayed if and only if URL is non-empty (hasFinancialAid=$hasFinancialAid)", + ({ hasFinancialAid, expectLink }) => { + const financialAidUrl = hasFinancialAid + ? `/financial-aid/${faker.string.alphanumeric(10)}` + : "" + const program = factories.programs.program({ + page: { financial_assistance_form_url: financialAidUrl }, }) - expect(link).toBeNull() - } - }, - ) + renderWithProviders() + + const priceRow = screen.getByTestId(TestIds.PriceRow) + + if (expectLink) { + const link = within(priceRow).getByRole("link", { + name: /financial assistance/i, + }) + const expectedUrl = new URL( + financialAidUrl, + process.env.NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL, + ).toString() + expect(link).toHaveAttribute("href", expectedUrl) + expect(link).toHaveTextContent("Financial assistance available") + } else { + const link = within(priceRow).queryByRole("link", { + name: /financial assistance/i, + }) + expect(link).toBeNull() + } + }, + ) + }) }) diff --git a/frontends/main/src/app-pages/ProductPages/ProductSummary.tsx b/frontends/main/src/app-pages/ProductPages/ProductSummary.tsx index fcfab82b9a..d67579b005 100644 --- a/frontends/main/src/app-pages/ProductPages/ProductSummary.tsx +++ b/frontends/main/src/app-pages/ProductPages/ProductSummary.tsx @@ -1,5 +1,5 @@ import React, { HTMLAttributes, useState } from "react" -import { Alert, styled, VisuallyHidden } from "@mitodl/smoot-design" +import { ActionButton, Alert, styled } from "@mitodl/smoot-design" import { productQueries } from "api/mitxonline-hooks/products" import { Dialog, Link, Skeleton, Stack, Typography } from "ol-components" import type { StackProps } from "ol-components" @@ -10,6 +10,7 @@ import { RiTimeLine, RiFileCopy2Line, RiMenuAddLine, + RiInformation2Line, } from "@remixicon/react" import { formatDate, isInPast, LocalDate, NoSSR, pluralize } from "ol-utilities" import type { @@ -41,16 +42,29 @@ const InfoRow = styled.div(({ theme }) => ({ width: "100%", display: "flex", gap: "8px", - alignItems: "baseline", + alignItems: "flex-start", color: theme.custom.colors.darkGray2, ...theme.typography.body2, [theme.breakpoints.down("sm")]: { ...theme.typography.body3, }, - svg: { +})) + +/** + * Centers an icon within a flex row. Uses height matching the text line-height + * so that flex-start alignment on the parent keeps it pinned to the first line. + */ +const InfoRowIcon = styled.span(({ theme }) => ({ + display: "inline-flex", + alignItems: "center", + height: theme.typography.body2.lineHeight, + [theme.breakpoints.down("sm")]: { + height: theme.typography.body3.lineHeight, + }, + flexShrink: 0, + "> svg": { width: "20px", height: "20px", - transform: "translateY(25%)", }, })) @@ -80,7 +94,7 @@ const InfoLabel = styled.span<{ underline && { textDecoration: "underline" }, ]) const InfoLabelValue: React.FC<{ - label: string + label: React.ReactNode value: React.ReactNode labelVariant?: "light" | "normal" }> = ({ label, value, labelVariant }) => @@ -128,7 +142,9 @@ const CourseDatesRow: React.FC = ({ return ( -