diff --git a/.env b/.env index 8f36e198c..c030531af 100755 --- a/.env +++ b/.env @@ -11,7 +11,7 @@ # Only `BOOL_` options become boolean values, and ONLY `true` evaluates to true # # Docker/Dev notes: -# docker/config/materia-docker.env.local is loaded instead of .env.local +# docker/.env.local is used instead of .env.local # GENERAL =================== @@ -34,12 +34,17 @@ BOOL_SEND_EMAILS=false #URLS_STATIC= #URLS_ENGINES= #BOOL_ADMIN_UPLOADER_ENABLE=true -#ASSET_STORAGE_DRIVER=file -#ASSET_STORAGE_S3_REGION=us-east-1 -#ASSET_STORAGE_S3_BUCKET= -#ASSET_STORAGE_S3_BASEPATH= -#ASSET_STORAGE_S3_KEY= -#ASSET_STORAGE_S3_SECRET= +ASSET_STORAGE_DRIVER=file # file | s3 | db (db not recommended) + +# AWS S3 =================== + +# ASSET_STORAGE_S3_REGION=us-east-1 +# ASSET_STORAGE_S3_BASEPATH=media +# ASSET_STORAGE_S3_BUCKET= +# ASSET_STORAGE_S3_ENDPOINT= # endpoint not required for S3 on AWS +# AWS_ACCESS_KEY_ID= +# AWS_SECRET_ACCESS_KEY= +# AWS_SESSION_TOKEN= # STS token for s3 development # SESSION & CACHE =================== @@ -88,3 +93,18 @@ LTI_KEY="materia-production-lti-key" #BOOL_LTI_USE_LAUNCH_ROLES=true #BOOL_LTI_GRACEFUL_CONFIG_FALLBACK=true #BOOL_LTI_LOG_FOR_DEBUGGING=false + +# Question Generation === + +#GENERATION_ENABLED=true +#GENERATION_ALLOW_IMAGES=false +#GENERATION_API_PROVIDER= +#GENERATION_API_ENDPOINT= +#GENERATION_API_KEY= +#GENERATION_API_VERSION= +#GENERATION_API_MODEL= +#GENERATION_LOG_STATS=true + +# webserver settings ======= + +#IS_SERVER_HTTPS=true \ No newline at end of file diff --git a/.github/workflows/test_and_build.yml b/.github/workflows/test_and_build.yml index b6a6609ea..30f47749b 100644 --- a/.github/workflows/test_and_build.yml +++ b/.github/workflows/test_and_build.yml @@ -29,17 +29,39 @@ jobs: - name: Checkout code uses: actions/checkout@v2 - - name: Build App Image + - name: Create .env.local to satisfy build requirements run: | cd docker - docker-compose build --no-cache webserver app + if [ ! -f .env.local ]; then + touch .env.local + fi - - name: Push App Images + - name: Build App and Webserver Images run: | - docker tag ucfopen/materia:app-dev ghcr.io/${{ github.repository_owner }}/materia:app-${{ github.sha }} - docker tag ucfopen/materia:app-dev ghcr.io/${{ github.repository_owner }}/materia:app-${{ steps.tag_name.outputs.GIT_TAG }} - docker tag ucfopen/materia:webserver-dev ghcr.io/${{ github.repository_owner }}/materia:webserver-${{ github.sha }} - docker tag ucfopen/materia:webserver-dev ghcr.io/${{ github.repository_owner }}/materia:webserver-${{ steps.tag_name.outputs.GIT_TAG }} + cd docker + docker compose build --no-cache webserver app fakes3 + + - name: Push Dev App and Webserver Images + if: ${{ startsWith(github.ref, 'refs/tags/v') && (contains(github.ref, '-alpha') || contains(github.ref, '-rc')) }} + run: | + docker push ghcr.io/${{ github.repository_owner }}/materia:app-dev + docker push ghcr.io/${{ github.repository_owner }}/materia:webserver-dev + docker push ghcr.io/${{ github.repository_owner }}/materia:fake-s3-dev + + - name: Push Stable App and Webserver Images + if: ${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-alpha') && !contains(github.ref, '-rc') }} + run: | + docker tag ghcr.io/${{ github.repository_owner }}/materia:app-dev ghcr.io/${{ github.repository_owner }}/materia:app-stable + docker tag ghcr.io/${{ github.repository_owner }}/materia:webserver-dev ghcr.io/${{ github.repository_owner }}/materia:webserver-stable + docker push ghcr.io/${{ github.repository_owner }}/materia:app-stable + docker push ghcr.io/${{ github.repository_owner }}/materia:webserver-stable + + - name: Push Versioned App and Webserver Images + run: | + docker tag ghcr.io/${{ github.repository_owner }}/materia:app-dev ghcr.io/${{ github.repository_owner }}/materia:app-${{ github.sha }} + docker tag ghcr.io/${{ github.repository_owner }}/materia:app-dev ghcr.io/${{ github.repository_owner }}/materia:app-${{ steps.tag_name.outputs.GIT_TAG }} + docker tag ghcr.io/${{ github.repository_owner }}/materia:webserver-dev ghcr.io/${{ github.repository_owner }}/materia:webserver-${{ github.sha }} + docker tag ghcr.io/${{ github.repository_owner }}/materia:webserver-dev ghcr.io/${{ github.repository_owner }}/materia:webserver-${{ steps.tag_name.outputs.GIT_TAG }} docker push ghcr.io/${{ github.repository_owner }}/materia:app-${{ github.sha }} docker push ghcr.io/${{ github.repository_owner }}/materia:app-${{ steps.tag_name.outputs.GIT_TAG }} docker push ghcr.io/${{ github.repository_owner }}/materia:webserver-${{ github.sha }} @@ -63,7 +85,7 @@ jobs: overwrite: true - name: Upload to Pre-Release - if: ${{ startsWith(github.ref, 'refs/tags/v') && contains(github.ref, '-alpha') && contains(github.ref, '-rc') }} + if: ${{ startsWith(github.ref, 'refs/tags/v') && contains(github.ref, '-alpha') || contains(github.ref, '-rc') }} uses: svenstaro/upload-release-action@v2 with: repo_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 605b7ac7c..b43a8c2ea 100644 --- a/.gitignore +++ b/.gitignore @@ -85,6 +85,7 @@ public/js/materia.storage.table.js public/js/student.js public/js/vendor/* +public/openai_usage.txt # Installed Widgets public/widget diff --git a/README.md b/README.md index eb753a641..40a50971e 100644 --- a/README.md +++ b/README.md @@ -48,11 +48,11 @@ cd Materia/docker ./run_first.sh ``` -The `run_first.sh` script only has to be run once for initial setup. Afterwards, your local copy will persist in a docker volume unless you explicitly use `docker-compose down` or delete the volume manually. +The `run_first.sh` script only has to be run once for initial setup. Afterwards, your local copy will persist in a docker volume unless you explicitly use `docker compose down` or delete the volume manually. -Use `docker-compose up` to run your local instance. The compose process must persist to keep the application alive. Materia is configured to run at `https://127.0.0.1` by default. +Use `docker compose up` to run your local instance. The compose process must persist to keep the application alive. Materia is configured to run at `https://127.0.0.1` by default. -In a separate terminal window, run `yarn dev` to enable the webpack dev server and live reloading while making changes to JS and CSS assets. +In a separate terminal window, run `yarn dev` to enable the webpack dev server and live reloading while making changes to JS and CSS assets. Note that Materia uses a self-signed certificate to facilitate https traffic locally. Your browser may require security exceptions for both `127.0.0.1:443` and `127.0.0.1:8008`. @@ -89,4 +89,23 @@ Materia supports two forms of authentication: ## Asset Storage -Materia enables users to upload media assets for their widgets, including images and audio. There are two asset storage drivers available out of the box: `file` and `db`. `file` is the default asset storage driver, which can be explicitly set via the `ASSET_STORAGE_DRIVER` environment variable. \ No newline at end of file +Users can upload media assets (images and audio) for use in their widgets, facilitated through a media importer that is provided by Materia itself. Asset storage drivers include: + +- `file`: Assets are stored on the local filesystem of the application. It is recommended that assets are backed up and synced with an external storage solution (such as S3) to ensure the files persist across application instances. +- `s3`: Files are uploaded to and requested directly from AWS S3. This is the most straightforward and recommended storage driver option. Be sure to consult the [Materia Docker Readme](docker/README.md) for additional environment variables associated with using S3. +- `db`: This storage driver stores asset binaries directly in the database. This option allows Materia to run on cloud hosting options with very limited storage volumes. The `db` storage driver option is not recommended for general use. + +> [!WARNING] +> The `db` asset storage driver option is deprecated and will be removed in the next major version of Materia. + +The storage driver is configured via the `ASSET_STORAGE_DRIVER` environment variable. + +### Local Asset Storage With S3 + +A `fakes3` container is instantiated as part of the default development stack and the `ASSET_STORAGE_DRIVER` environment variable is set to `s3` by default in the development `.env` file located in `docker/.env`. When using `fakes3`, this is all that is required to simulate S3 usage locally. + +To use an actual S3 bucket for local dev: + +1. Set `DEV_ONLY_FAKES3_DISABLED` environment variable in `docker/.env` to `true` +2. Set `ASSET_STORAGE_S3_BUCKET` to your bucket name +3. Set `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_SESSION_TOKEN` in `.env.local`. (Tip: You can run `aws configure export-credentials --profile YOUR_PROFILE_NAME --format env-no-export` to get these) diff --git a/composer.json b/composer.json index 8ccb4f868..eb2f0bc63 100644 --- a/composer.json +++ b/composer.json @@ -50,9 +50,10 @@ "phpseclib/phpseclib": "~3.0", "phpseclib/phpseclib2_compat":"~1.0", "eher/oauth": "1.0.7", - "aws/aws-sdk-php": "3.288.1", + "aws/aws-sdk-php": "^3.314", "symfony/dotenv": "^5.1", - "ucfopen/materia-theme-ucf": "2.0.3" + "ucfopen/materia-theme-ucf": "2.0.4", + "openai-php/client": "^0.8.5" }, "suggest": { "ext-memcached": "*" @@ -99,9 +100,9 @@ "package": { "name": "ucfopen/materia-theme-ucf", "type": "fuel-package", - "version": "2.0.3", + "version": "2.0.4", "dist": { - "url": "https://github.com/ucfopen/Materia-Theme-UCF/archive/refs/tags/v2.0.3.zip", + "url": "https://github.com/ucfopen/Materia-Theme-UCF/archive/refs/tags/v2.0.4.zip", "type": "zip" }, "source": { @@ -114,5 +115,14 @@ } ], "minimum-stability": "dev", - "prefer-stable": true + "prefer-stable": true, + "autoload": { + "psr-0": { + "": "*" + }, + "psr-4": { + "S3\\": "../s3/", + "AwsUtilities\\": "../aws_utilities/" + } + } } diff --git a/composer.lock b/composer.lock index 677151716..9cf9815b4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "be6957863281d2e297c8a3fbff404a13", + "content-hash": "d18a4cbef97736f368722e209e282823", "packages": [ { "name": "aws/aws-crt-php", @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.288.1", + "version": "3.316.3", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "a1dfa12c7165de0b731ae8074c4ba1f3ae733f89" + "reference": "e832e594b3c213760e067e15ef2739f77505e832" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/a1dfa12c7165de0b731ae8074c4ba1f3ae733f89", - "reference": "a1dfa12c7165de0b731ae8074c4ba1f3ae733f89", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/e832e594b3c213760e067e15ef2739f77505e832", + "reference": "e832e594b3c213760e067e15ef2739f77505e832", "shasum": "" }, "require": { @@ -151,9 +151,9 @@ "support": { "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.288.1" + "source": "https://github.com/aws/aws-sdk-php/tree/3.316.3" }, - "time": "2023-11-22T19:35:38+00:00" + "time": "2024-07-12T18:07:23+00:00" }, { "name": "composer/installers", @@ -386,12 +386,12 @@ "source": { "type": "git", "url": "https://github.com/fuel/core.git", - "reference": "44b276d824e3a5f48a269bfec11c5432571083d1" + "reference": "abf0f371710a405d03ee19a9ecf67e2e9233b2fb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/fuel/core/zipball/44b276d824e3a5f48a269bfec11c5432571083d1", - "reference": "44b276d824e3a5f48a269bfec11c5432571083d1", + "url": "https://api.github.com/repos/fuel/core/zipball/abf0f371710a405d03ee19a9ecf67e2e9233b2fb", + "reference": "abf0f371710a405d03ee19a9ecf67e2e9233b2fb", "shasum": "" }, "require": { @@ -419,7 +419,7 @@ "issues": "https://github.com/fuel/core/issues", "source": "https://github.com/fuel/core/tree/1.9/develop" }, - "time": "2024-07-08T13:14:44+00:00" + "time": "2024-07-30T13:27:39+00:00" }, { "name": "fuel/email", @@ -1201,6 +1201,98 @@ "time": "2023-08-25T10:54:48+00:00" }, { + "name": "openai-php/client", + "version": "v0.8.5", + "source": { + "type": "git", + "url": "https://github.com/openai-php/client.git", + "reference": "0f755fafa4d3f8d5c8ed964d3166d078fac0605a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/openai-php/client/zipball/0f755fafa4d3f8d5c8ed964d3166d078fac0605a", + "reference": "0f755fafa4d3f8d5c8ed964d3166d078fac0605a", + "shasum": "" + }, + "require": { + "php": "^8.1.0", + "php-http/discovery": "^1.19.4", + "php-http/multipart-stream-builder": "^1.3.0", + "psr/http-client": "^1.0.3", + "psr/http-client-implementation": "^1.0.1", + "psr/http-factory-implementation": "*", + "psr/http-message": "^1.1.0|^2.0.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.8.1", + "guzzlehttp/psr7": "^2.6.2", + "laravel/pint": "^1.15.0", + "mockery/mockery": "^1.6.11", + "nunomaduro/collision": "^7.10.0", + "pestphp/pest": "^2.34.6", + "pestphp/pest-plugin-arch": "^2.7", + "pestphp/pest-plugin-type-coverage": "^2.8.1", + "phpstan/phpstan": "^1.10.66", + "rector/rector": "^1.0.4", + "symfony/var-dumper": "^6.4.4" + }, + "type": "library", + "autoload": { + "files": [ + "src/OpenAI.php" + ], + "psr-4": { + "OpenAI\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + }, + { + "name": "Sandro Gehri" + } + ], + "description": "OpenAI PHP is a supercharged PHP API client that allows you to interact with the Open AI API", + "keywords": [ + "GPT-3", + "api", + "client", + "codex", + "dall-e", + "language", + "natural", + "openai", + "php", + "processing", + "sdk" + ], + "support": { + "issues": "https://github.com/openai-php/client/issues", + "source": "https://github.com/openai-php/client/tree/v0.8.5" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/gehrisandro", + "type": "github" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2024-04-15T19:11:23+00:00" + }, + { "name": "paragonie/constant_time_encoding", "version": "v3.0.0", "source": { @@ -1403,6 +1495,141 @@ }, "time": "2024-04-24T12:06:31+00:00" }, + { + "name": "php-http/discovery", + "version": "1.19.4", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "0700efda8d7526335132360167315fdab3aeb599" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/0700efda8d7526335132360167315fdab3aeb599", + "reference": "0700efda8d7526335132360167315fdab3aeb599", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.19.4" + }, + "time": "2024-03-29T13:00:05+00:00" + }, + { + "name": "php-http/multipart-stream-builder", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/multipart-stream-builder.git", + "reference": "f5938fd135d9fa442cc297dc98481805acfe2b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/multipart-stream-builder/zipball/f5938fd135d9fa442cc297dc98481805acfe2b6a", + "reference": "f5938fd135d9fa442cc297dc98481805acfe2b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "php-http/discovery": "^1.15", + "psr/http-factory-implementation": "^1.0" + }, + "require-dev": { + "nyholm/psr7": "^1.0", + "php-http/message": "^1.5", + "php-http/message-factory": "^1.0.2", + "phpunit/phpunit": "^7.5.15 || ^8.5 || ^9.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Message\\MultipartStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + } + ], + "description": "A builder class that help you create a multipart stream", + "homepage": "http://php-http.org", + "keywords": [ + "factory", + "http", + "message", + "multipart stream", + "stream" + ], + "support": { + "issues": "https://github.com/php-http/multipart-stream-builder/issues", + "source": "https://github.com/php-http/multipart-stream-builder/tree/1.3.0" + }, + "time": "2023-04-28T14:10:22+00:00" + }, { "name": "phpseclib/phpseclib", "version": "3.0.39", @@ -2035,7 +2262,7 @@ }, { "name": "ucfopen/materia-theme-ucf", - "version": "2.0.3", + "version": "2.0.4", "source": { "type": "git", "url": "https://github.com/ucfopen/Materia-Theme-UCF.git", @@ -2043,7 +2270,7 @@ }, "dist": { "type": "zip", - "url": "https://github.com/ucfopen/Materia-Theme-UCF/archive/refs/tags/v2.0.3.zip" + "url": "https://github.com/ucfopen/Materia-Theme-UCF/archive/refs/tags/v2.0.4.zip" }, "type": "fuel-package" } @@ -3921,7 +4148,8 @@ }, "dist": { "type": "zip", - "url": "https://github.com/ucfcdl/fuelphp-phpcs/archive/v3.0.1.zip" + "url": "https://github.com/ucfcdl/fuelphp-phpcs/archive/v3.0.1.zip", + "reference": "v3.0.1" }, "type": "library" } diff --git a/docker/.env b/docker/.env index 2392fe5c5..6ffa4e44c 100644 --- a/docker/.env +++ b/docker/.env @@ -1,12 +1,52 @@ -# Database settings +## docker/.env contains environment variables used by Materia during local development +## we do not recommend making edits directly to this file. Instead, make a .env.local in the same directory (docker/) and override the values below as desired. + +# database settings MYSQL_ROOT_PASSWORD=drRoots MYSQL_USER=materia MYSQL_PASSWORD=odin MYSQL_DATABASE=materia -# passwords/hashes/eys +# passwords/hashes/keys DEV_ONLY_USER_PASSWORD=kogneato # see readme for how to create these DEV_ONLY_AUTH_SALT=111b776e5f862058e2e075b640b3de5fb601d0ac57639c733a2d10edffd2a3d5 DEV_ONLY_AUTH_SIMPLEAUTH_SALT=33e0d379060e3877d634632853c10a70dff9710b751e5af00a0f637884df417e DEV_ONLY_SECRET_CIPHER_KEY=e0beaea1704555ae3c75650703bb106fac24b8967c77a667124fbe745c3346ed + +# s3-specific asset storage values + +# overrides default value in the base .env (which isn't loaded into dev environment) +ASSET_STORAGE_DRIVER=s3 +# provider must be one of the following: env | imds +ASSET_STORAGE_S3_CREDENTIAL_PROVIDER=env +ASSET_STORAGE_S3_BUCKET=fake_bucket +ASSET_STORAGE_S3_ENDPOINT=http://fakes3:10001 +ASSET_STORAGE_S3_KEY=KEY +ASSET_STORAGE_S3_SECRET=SECRET + # set to true if using real S3 on development +# DEV_ONLY_FAKES3_DISABLED=false + +# question generation environment variables. Different variables are required depending on provider. + +# required to be true for generation to be enabled +GENERATION_ENABLED=false +# explicitly enable or disable image generation. defaults to false if not provided. +GENERATION_ALLOW_IMAGES=false +# required. provider must be one of the following: openai | azure_openai +GENERATION_API_PROVIDER=openai +# required for both +GENERATION_API_KEY= +# required for azure +GENERATION_API_ENDPOINT= +# required for azure +GENERATION_API_VERSION= +# required for openai +GENERATION_API_MODEL= + # not required. stat logging is set to debug threshold +GENERATION_LOG_STATS=true + +# webserver environment variables + +# not required, will default to true if unset. signifies if the webserver is using HTTPS or not +IS_SERVER_HTTPS=true \ No newline at end of file diff --git a/docker/README.md b/docker/README.md index f9263a42d..5dc4c068b 100644 --- a/docker/README.md +++ b/docker/README.md @@ -3,17 +3,17 @@ We publish production ready docker containers for each release in the [Materia GitHub Docker Repository](https://github.com/orgs/ucfopen/packages/container/package/materia). These images are built and published automatically using GitHub Actions on every tagged release. ``` -docker pull ghcr.io/ucfopen/materia:webserver-v8.0.0 -docker pull ghcr.io/ucfopen/materia:app-v8.0.0 +docker pull ghcr.io/ucfopen/materia:webserver-stable +docker pull ghcr.io/ucfopen/materia:app-stable ``` ## Container Architecture - 1. [webserver (Nginx)](https://www.nginx.com/) as a web server (proxies to phpfpm for app and serves static files directly) - 3. [app (PHP-FPM)](https://php-fpm.org/) manages the PHP processes for the application - 4. [mysql](https://www.mysql.com/) for storing relational application data - 5. [memcached](https://memcached.org/) for caching data and sessions - 6. [fakeS3](https://github.com/jubos/fake-s3) mocks AWS S3 behavior for asset uploading + 1. [webserver (Nginx)](https://www.nginx.com/) as a web server (proxies to phpfpm for app and serves static files directly). + 3. [app (PHP-FPM)](https://php-fpm.org/) manages the PHP processes for the application. + 4. [mysql](https://www.mysql.com/) for storing relational application data. + 5. [memcached](https://memcached.org/) for caching data and sessions. + 6. [fakeS3](https://github.com/jubos/fake-s3) mocks AWS S3 behavior for asset uploading. This should not be used in production. ## Setup @@ -25,15 +25,15 @@ Please take note of the user accounts that are created for you in the install pr * Run the containers after ./run_first.sh has finished ``` - docker-compose up + docker compose up ``` * Run the servers in background ``` - docker-compose up -d + docker compose up -d ``` * Tail logs from background process ``` - docker-compose logs -f app + docker compose logs -f app ``` * Run commands on the app container (like php, composer, or fuelphp oil commands) ``` @@ -43,16 +43,17 @@ Please take note of the user accounts that are created for you in the install pr ``` * Stop containers (db data is retained) ``` - docker-compose stop + docker compose stop ``` * Stop and destroy the containers (deletes database data!, first_run.sh required after) ``` - docker-compose down + docker compose down ``` * Compile the javascript and sass ``` ./run_build_assets.sh ``` + _Note:_ this is more easily accomplished by running `yarn dev` from the root Materia directory locally. * Install composer libraries ``` ./run.sh composer install @@ -65,25 +66,25 @@ Please take note of the user accounts that are created for you in the install pr ``` ./run_tests.sh ``` -* Run Tests for as like the CI server - ``` - ./run_tests_ci.sh - ``` * Run Tests with code coverage ``` ./run_tests_coverage.sh ``` * Create a user based on your docker host machine's current user ``` - $ iturgeon@ucf: ./run_create_me.sh - User Created: iturgeon password: kogneato - iturgeon now in role: super_user - iturgeon now in role: basic_author + $ kogneato@ucf: ./run_create_me.sh + User Created: kogneato password: max_power + kogneato now in role: super_user + kogneato now in role: basic_author ``` * Create the [default users outlined in the config](https://github.com/ucfopen/Materia/blob/master/fuel/app/config/materia.php#L56-L78) ``` ./run_create_default_users.sh ``` +* Create a user manually + ``` + ./run.sh php oil r admin:new_user username firstname mi lastname email password + ``` * Build a deployable materia package (zip w/ compiled assets, and dependencies; see [assets on our releases](https://github.com/ucfopen/Materia/releases)) ``` ./run_build_github_release_package.sh @@ -112,19 +113,23 @@ If you wish to log into Materia, there are [3 default accounts created for you b If you're wanting to update a php or mysql version, this can be done locally for testing before updating the global image. 1. finish your edits. -2. Execute `docker-compose build` to rebuild any images. -4. Removing any existing running container using that image: `docker-compose stop app` and `docker-compose rm app` -5. Start the desired container: `docker-compose up app` +2. Execute `docker compose build` to rebuild any images. +4. Removing any existing running container using that image: `docker compose stop app` and `docker compose rm app` +5. Start the desired container: `docker compose up app` ## Production Ready Docker Compose -If you plan on deploying a production server using these docker images, we suggest using docker-compose. You will probably want to have an external database service (like AWS's RDS), and you'll need a place to keep backups of any uploaded files. +If you plan on deploying a production server using these docker images, we suggest using docker compose. You will probably want to have an external database service (like AWS's RDS), and you'll need a place to keep backups of any uploaded files. ### Dynamic Files to Backup -* MySQL Database Contents -* Uploaded Media -* Installed Widget Engine Files +* MySQL Database Contents: +* Uploaded Media (generally `$APP_DIR/media`) +* Installed Widget Engine Files (generally `$APP_DIR/widgets`) + +### Environment Variables + +Refer to the [Server Variables](https://ucfopen.github.io/Materia-Docs/admin/server-variables.html) page on our docs site for environment variable configuration options. ### Sample Docker Compose @@ -133,7 +138,7 @@ version: '3.5' services: webserver: - image: ghcr.io/ucfopen/materia:webserver-v8.0.0 + image: ghcr.io/ucfopen/materia:webserver-stable ports: # 443 would be terminated at the load balancer # Some customization required to terminate 443 here (see dev nginx config) @@ -149,7 +154,7 @@ services: - app app: - image: ghcr.io/ucfopen/materia:app-v8.0.0 + image: ghcr.io/ucfopen/materia:app-stable env_file: # View Materia Readme for ENV vars - .env @@ -196,7 +201,6 @@ volumes: ### Troubleshooting -#### Table Not Found +#### Table Not Found or PDO Exceptions During Installation When running fuelphp's install, it uses fuel/app/config/development/migrations.php file to know the current state of your database. Fuel assumes this file is truth, and won't create tables even on an empty database. You probably need to delete the file and run the setup scripts again. run_first.sh does this for you if needed. - diff --git a/docker/config/php/materia.php.ini b/docker/config/php/materia.php.ini index 9fdebe247..1e07d5bb6 100644 --- a/docker/config/php/materia.php.ini +++ b/docker/config/php/materia.php.ini @@ -2,7 +2,7 @@ short_open_tag = Off expose_php = off max_execution_time = 100 -memory_limit = 250M +memory_limit = 256M track_errors = Off html_errors = On variables_order = "EGPCS" diff --git a/docker/docker-compose.override.test.yml b/docker/docker-compose.override.test.yml index 2115b1a11..9620730ea 100644 --- a/docker/docker-compose.override.test.yml +++ b/docker/docker-compose.override.test.yml @@ -24,6 +24,10 @@ services: - uploaded_media_test:/var/www/html/fuel/packages/materia/media - ./config/php/materia.test.php.ini:/usr/local/etc/php/conf.d/test.ini - ./dockerfiles/wait-for-it.sh:/wait-for-it.sh + depends_on: + - fakes3_test + - mysql + - memcached mysql: environment: @@ -36,9 +40,23 @@ services: # tmpfs: # - /var/lib/mysql - fakes3: + # fakes3, when added as a dependency in the app container above, would restart + # and lose its data during tests + # thus, fakes3_test was created. it is dropped after tests are complete + fakes3_test: + image: ucfopen/materia:fake-s3-dev + build: + context: ../ + dockerfile: materia-fake-s3.Dockerfile + ports: + # use separate port to avoid conflicts with fakes3 + - "10002:10001" volumes: - - uploaded_media_test:/s3mnt/fakes3_root/fakes3_uploads/media/ + # use separate volume to avoid conflicts with fakes3 + - uploaded_media_test:/s3mnt/fakes3_root/fake_bucket/media/ + networks: + - frontend + - backend volumes: # static_files: {} # compiled js/css and uploaded widgets diff --git a/docker/docker-compose.override.yml b/docker/docker-compose.override.yml index a705ea366..06ac82046 100644 --- a/docker/docker-compose.override.yml +++ b/docker/docker-compose.override.yml @@ -15,13 +15,21 @@ services: - ./config/nginx/nginx-dev.conf:/etc/nginx/nginx.conf:ro app: + env_file: + - .env + - .env.local volumes: - ..:/var/www/html/ - uploaded_widgets:/var/www/html/public/widget/ - ./dockerfiles/wait-for-it.sh:/wait-for-it.sh + depends_on: + - fakes3 + - mysql + - memcached mysql: environment: + # values sourced from docker/env - MYSQL_ROOT_PASSWORD - MYSQL_USER - MYSQL_PASSWORD @@ -29,7 +37,7 @@ services: fakes3: volumes: - - uploaded_media:/s3mnt/fakes3_root/fakes3_uploads/media/ + - uploaded_media:/s3mnt/fakes3_root/fake_bucket/media/ volumes: # static_files: {} # compiled js/css and uploaded widgets diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 25c26347b..59818fd82 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -2,7 +2,7 @@ version: '3.5' services: webserver: - image: ucfopen/materia:webserver-dev + image: ghcr.io/ucfopen/materia:webserver-dev build: context: ../ dockerfile: materia-webserver.Dockerfile @@ -16,17 +16,13 @@ services: - app app: - image: ucfopen/materia:app-dev + image: ghcr.io/ucfopen/materia:app-dev build: context: ../ dockerfile: materia-app.Dockerfile environment: # View Materia README for env settings - - ASSET_STORAGE_DRIVER=file - - ASSET_STORAGE_S3_BUCKET=fake_bucket - - ASSET_STORAGE_S3_ENDPOINT=http://fakes3:10001 - - ASSET_STORAGE_S3_KEY=KEY - - ASSET_STORAGE_S3_SECRET=SECRET + - ASSET_STORAGE_DRIVER=${ASSET_STORAGE_DRIVER} - AUTH_DRIVERS=Materiaauth - AUTH_SALT=${DEV_ONLY_AUTH_SALT} - AUTH_SIMPLEAUTH_SALT=${DEV_ONLY_AUTH_SIMPLEAUTH_SALT} @@ -54,13 +50,9 @@ services: networks: - frontend - backend - depends_on: - - mysql - - memcached - - fakes3 mysql: - image: mysql:5.7.34 + image: mysql:8.0.32 platform: linux/amd64 ports: - "3306:3306" # allow mysql access from the host - use /etc/hosts to set mysql to your docker-machine ip @@ -75,7 +67,7 @@ services: - backend fakes3: - image: ucfopen/materia:fake-s3-dev + image: ghcr.io/ucfopen/materia:fake-s3-dev build: context: ../ dockerfile: materia-fake-s3.Dockerfile diff --git a/docker/run.sh b/docker/run.sh index 0483ece12..344c84fea 100755 --- a/docker/run.sh +++ b/docker/run.sh @@ -13,4 +13,4 @@ set -e -docker-compose run --rm app /wait-for-it.sh mysql:3306 -t 20 -- "$@" +docker compose run --rm app /wait-for-it.sh mysql:3306 -t 20 -- "$@" diff --git a/docker/run_create_default_users.sh b/docker/run_create_default_users.sh index 5b6d887df..780aa9f5f 100755 --- a/docker/run_create_default_users.sh +++ b/docker/run_create_default_users.sh @@ -10,4 +10,4 @@ ####################################################### # create/update the default users -docker-compose run --rm app bash -c "php oil r admin:create_default_users" +docker compose run --rm app bash -c "php oil r admin:create_default_users" diff --git a/docker/run_create_me.sh b/docker/run_create_me.sh index 3fffc3d05..8b8d9aaec 100755 --- a/docker/run_create_me.sh +++ b/docker/run_create_me.sh @@ -13,7 +13,7 @@ PASS=${MATERIA_DEV_PASS:-kogneato} # create or update the user and pw -docker-compose run --rm app bash -c "php oil r admin:new_user $USER $USER M Lastname $USER@mail.com $PASS || php oil r admin:reset_password $USER $PASS" +docker compose run --rm app bash -c "php oil r admin:new_user $USER $USER M Lastname $USER@mail.com $PASS || php oil r admin:reset_password $USER $PASS" # give them super_user and basic_author -docker-compose run --rm app bash -c "php oil r admin:give_user_role $USER super_user || true && php oil r admin:give_user_role $USER basic_author" +docker compose run --rm app bash -c "php oil r admin:give_user_role $USER super_user || true && php oil r admin:give_user_role $USER basic_author" diff --git a/docker/run_first.sh b/docker/run_first.sh index ed52d00c5..7a61ccef3 100755 --- a/docker/run_first.sh +++ b/docker/run_first.sh @@ -5,7 +5,7 @@ # Initializes a new local Dev Materia environment in Docker # # If you find you really need to burn everything down -# Run "docker-compose down" to get rid of all containers +# Run "docker compose down" to get rid of all containers # ####################################################### set -e @@ -20,30 +20,63 @@ rm -f ../fuel/app/config/**/migrations.php rm -rf ./config/nginx/key.pem rm -rf ./config/nginx/cert.pem +if [ ! -f .env.local ]; then + touch .env.local +fi + # generate a self-signed ssl cert openssl req -subj '/CN=localhost' -x509 -newkey rsa:4096 -nodes -keyout ./config/nginx/key.pem -out ./config/nginx/cert.pem -days 365 -# quietly pull any docker images we can -docker-compose pull --ignore-pull-failures +echo " + __ __ ______ ______ ______ ______ __ ______ +/\ \-./ \ /\ __ \ /\__ _\ /\ ___\ /\ == \ /\ \ /\ __ \ +\ \ \-./\ \ \ \ __ \ \/_/\ \/ \ \ __\ \ \ __< \ \ \ \ \ __ \ + \ \_\ \ \_\ \ \_\ \_\ \ \_\ \ \_____\ \ \_\ \_\ \ \_\ \ \_\ \_\ + \/_/ \/_/ \/_/\/_/ \/_/ \/_____/ \/_/ /_/ \/_/ \/_/\/_/ +" + +echo "To setup Materia locally, you can choose to pull pre-packaged images or build from source" +echo "1. Pull app and webserver images (recommended if you just want to run Materia locally with no dev)" +echo "2. Build images from source (recommended if you're actively developing Materia)" +read -p "Enter an option (1 or 2): " choice + +if [ "$choice" == "1" ]; then + echo "Pulling containers..." + # quietly pull any docker images we can + docker compose pull + + +elif [ "$choice" == "2" ]; then + echo "Building containers. This will take a few minutes..." + docker compose build app webserver fakes3 + +else + echo "Invalid choice. Try again." + exit 1 +fi -# install php composer deps -docker-compose run --rm --no-deps app composer install --ignore-platform-reqs +# Install php composer deps +# Even though these are present on the images already, the assets don't exist locally +# When docker compose volume mounts the project, the local filesystem takes precedence +# As such it's required to rerun this process to bring the host machine into parity +docker compose run --rm --no-deps app composer install --ignore-platform-reqs # run migrations and seed any db data needed for a new install -docker-compose run --rm app /wait-for-it.sh mysql:3306 --timeout=120 --strict -- composer oil-install-quiet +docker compose run --rm app /wait-for-it.sh mysql:3306 --timeout=120 --strict -- composer oil-install-quiet # install all the configured widgets -docker-compose run --rm app bash -c 'php oil r widget:install_from_config' +docker compose run --rm app bash -c 'php oil r widget:install_from_config' # Install any widgets in the tmp dir source run_widgets_install.sh '*.wigt' -# build all the js/css assets +# Same deal as composer: the assets are available in the images but not locally source run_build_assets.sh # create a dev user based on your current shell user (password will be 'kogneato') MATERIA_DEV_PASS=whatever can be used to set a custom pw source run_create_me.sh echo -e "Materia will be hosted on \033[32m$DOCKER_IP\033[0m" -echo -e "\033[1mRun an oil comand:\033[0m ./run.sh php oil r widget:show_engines" -echo -e "\033[1mRun the web app:\033[0m docker-compose up" +echo -e '\033[1mRun an oil comand:\033[0m ./run.sh php oil r widget:show_engines' +echo -e '\033[1mRun the web app:\033[0m docker compose up' +echo -e 'Doing local dev? Be sure to \033[1myarn install\033[0m and \033[1myarn dev\033[0m to run the local webpack dev server' diff --git a/docker/run_tests.sh b/docker/run_tests.sh index 3e6660317..28633fff6 100755 --- a/docker/run_tests.sh +++ b/docker/run_tests.sh @@ -10,9 +10,18 @@ echo "remember you can limit your test groups with './run_tests.sh --group=Lti'" # If you have an issue with a broken widget package breaking this script, run the following to clear the widgets # docker-compose -f docker-compose.yml -f docker-compose.admin.yml run --rm app bash -c -e 'rm /var/www/html/fuel/packages/materia/vendor/widget/test/*' -DCTEST="docker-compose -f docker-compose.yml -f docker-compose.override.test.yml" +DCTEST="docker compose -f docker-compose.yml -f docker-compose.override.test.yml" set -e set -o xtrace $DCTEST run -T --rm app /wait-for-it.sh mysql:3306 -t 20 -- composer run testci -- "$@" + +# Remove fakes3_test container +CONTAINER_ID=$(docker-compose -f docker-compose.yml -f docker-compose.override.test.yml ps -q fakes3_test) +if [ -z "$CONTAINER_ID" ]; then + echo "fakes3_test container not found" +else + docker stop $CONTAINER_ID + docker rm $CONTAINER_ID +fi \ No newline at end of file diff --git a/docker/run_tests_ci.sh b/docker/run_tests_ci.sh index f65fa05d9..6a8a23116 100755 --- a/docker/run_tests_ci.sh +++ b/docker/run_tests_ci.sh @@ -11,7 +11,7 @@ set -e set -o xtrace -DCTEST="docker-compose -f docker-compose.yml -f docker-compose.override.test.yml" +DCTEST="docker compose -f docker-compose.yml -f docker-compose.override.test.yml" $DCTEST pull --ignore-pull-failures app fakes3 diff --git a/docker/run_tests_coverage.sh b/docker/run_tests_coverage.sh index 5b3d9c511..0d85877ec 100755 --- a/docker/run_tests_coverage.sh +++ b/docker/run_tests_coverage.sh @@ -13,7 +13,7 @@ ####################################################### set -e -DCTEST="docker-compose -f docker-compose.yml -f docker-compose.override.test.yml" +DCTEST="docker compose -f docker-compose.yml -f docker-compose.override.test.yml" echo "remember you can limit your test groups with './run_tests_coverage.sh --group=Lti'" echo "If you have an issue with a broken widget, clear the widgets with:" diff --git a/docker/run_tests_lint.sh b/docker/run_tests_lint.sh index 945ec6bb0..19a9f3ee6 100755 --- a/docker/run_tests_lint.sh +++ b/docker/run_tests_lint.sh @@ -5,7 +5,7 @@ # Script to run the linter in docker ####################################################### -DCTEST="docker-compose -f docker-compose.yml -f docker-compose.override.test.yml" +DCTEST="docker compose -f docker-compose.yml -f docker-compose.override.test.yml" set -e set -o xtrace diff --git a/docker/run_widgets_install.sh b/docker/run_widgets_install.sh index 2b45bbbf8..d4669b86d 100755 --- a/docker/run_widgets_install.sh +++ b/docker/run_widgets_install.sh @@ -13,4 +13,4 @@ ####################################################### set -e -docker-compose run --rm app bash -c 'php oil r widget:install fuel/app/tmp/widget_packages/'$1 +docker compose run --rm app bash -c 'php oil r widget:install fuel/app/tmp/widget_packages/'$1 diff --git a/fuel/app/bootstrap.php b/fuel/app/bootstrap.php index 69659072b..49fa83b97 100644 --- a/fuel/app/bootstrap.php +++ b/fuel/app/bootstrap.php @@ -16,8 +16,7 @@ 'Cache' => $materia_path.'/fuel/core/cache.php', 'Fuel\\Core\\Errorhandler' => $materia_path.'/fuel/core/errorhandler.php', 'Log' => $materia_path.'/fuel/core/log.php', - 'TestCase' => $materia_path.'/fuel/core/testcase.php', - 'Cookie' => $materia_path.'/fuel/core/cookie.php' + 'TestCase' => $materia_path.'/fuel/core/testcase.php' // TODO: build task that will resolve/populate all the classes in materia here ]); diff --git a/fuel/app/classes/basetest.php b/fuel/app/classes/basetest.php index 25ab46453..311650959 100644 --- a/fuel/app/classes/basetest.php +++ b/fuel/app/classes/basetest.php @@ -102,6 +102,8 @@ protected function make_disposable_widget(string $name = 'TestWidget', bool $res 'is_playable' => true, 'is_editable' => true, 'in_catalog' => true, + 'is_generable' => false, + 'uses_prompt_generation' => false, 'restrict_publish' => $restrict_publish, 'api_version' => 2, ], diff --git a/fuel/app/classes/controller/media.php b/fuel/app/classes/controller/media.php index 9b50af709..f6b5cd4e6 100644 --- a/fuel/app/classes/controller/media.php +++ b/fuel/app/classes/controller/media.php @@ -104,12 +104,20 @@ public function action_upload() ]; $name = Input::post('name', 'New Asset'); - $asset = Widget_Asset_Manager::new_asset_from_file($name, $file_info); + + try { + $asset = Widget_Asset_Manager::new_asset_from_file($name, $file_info); + } + catch (\Exception $e) { + $res->body('{"error":{"message":"Unable to save new asset"}}'); + $res->set_status(400); + return $res; + } if ( ! $asset || ! isset($asset->id)) { // error - trace('Unable to create asset'); + \Log::Error('Unable to create asset'); $res->body('{"error":{"code":"16","message":"Unable to save new asset"}}'); $res->set_status(400); return $res; diff --git a/fuel/app/classes/controller/qsets.php b/fuel/app/classes/controller/qsets.php index b639486e0..fa1c1d119 100644 --- a/fuel/app/classes/controller/qsets.php +++ b/fuel/app/classes/controller/qsets.php @@ -27,4 +27,25 @@ public function action_import() return Response::forge($theme->render()); } + + public function action_generate() + { + // Validate Logged in + if (\Service_User::verify_session() !== true ) throw new HttpNotFoundException; + + $theme = Theme::instance(); + $theme->set_template('layouts/react'); + $theme->get_template() + ->set('title', 'QSet Generation') + ->set('page_type', 'generate'); + + Js::push_inline('var BASE_URL = "'.Uri::base().'";'); + Js::push_inline('var WIDGET_URL = "'.Config::get('materia.urls.engines').'";'); + Js::push_inline('var STATIC_CROSSDOMAIN = "'.Config::get('materia.urls.static').'";'); + + Css::push_group(['qset_generator']); + Js::push_group(['react', 'qset_generator']); + + return Response::forge($theme->render()); + } } diff --git a/fuel/app/classes/materia/api/v1.php b/fuel/app/classes/materia/api/v1.php index f045dde1f..bc8d2538f 100644 --- a/fuel/app/classes/materia/api/v1.php +++ b/fuel/app/classes/materia/api/v1.php @@ -109,7 +109,7 @@ static public function widget_instance_access_perms_verify($inst_id) * @return object, contains properties indicating whether the current * user can edit the widget and a message object describing why, if not */ - + // !! this endpoint should be significantly refactored or removed in the future API overhaul !! static public function widget_instance_edit_perms_verify(string $inst_id) { @@ -841,6 +841,87 @@ static public function question_set_get($inst_id, $play_id = null, $timestamp = return $inst->qset; } + /** + * Generates a question set based on a given instance ID, widget ID, topic, and whether to include images. + * @param string $inst_id The instance ID, if there is an instance associated with this request. May be null. + * @param string $widget_id The ID of the widget engine associated with this request. Must be set. + * @param string $topic The topic for which to generate a question set + * @param bool $include_images whether or not to include images in the generated qset + * @param int $num_questions How many questions should be generated in the qset + * @param bool $build_off_existing Whether to build from an existing qset, or generate one from scratch + * @return object The generated question set + */ + static public function question_set_generate($inst_id, $widget_id, $topic, $include_images, $num_questions, $build_off_existing) + { + // short-circuit if generation is not available + if ( ! Widget_Question_Generator::is_enabled()) return Msg::failure(); + + // verify eligibility + if ( ! \Service_User::verify_session(['basic_author', 'super_user'])) return Msg::no_perm(); + + $inst = null; + + // validate instance (but only if an instance id is provided) + if (Util_Validator::is_valid_hash($inst_id)) + { + if ( ! ($inst = Widget_Instance_Manager::get($inst_id))) throw new \HttpNotFoundException; + if ( ! $inst->playable_by_current_user()) return Msg::no_login(); + } + + $widget = new Widget(); + if ( $widget->get($widget_id) == false) return Msg::invalid_input('Invalid widget type'); + if ( ! $widget->is_generable) return Msg::invalid_input('Widget engine does not support generation'); + + // clean topic of any special characters + $topic = preg_replace('/[^a-zA-Z0-9\s]/', '', $topic); + + // validate number of questions + if ($num_questions < 1) $num_questions = 8; + if ($num_questions > 32) $num_questions = 32; + + $query = Widget_Question_Generator::generate_qset($inst, $widget, $topic, $include_images, $num_questions, $build_off_existing); + if ( ! $query instanceof Msg && is_array($query)) + { + return [ + ...$query, + 'title' => $topic + ]; + } + else + { + \Log::error(print_r($query, true)); + return $query; + } + } + + /** + * Endpoint to facilitate AI text generation for widgets + * + * @param string $prompt The prompt to generate. + * @return array An array to be passed back to the widget containing the response string + */ + static public function widget_prompt_generate($prompt) + { + // verify eligibility + if ( ! Widget_Question_Generator::is_enabled()) return Msg::failure(); + if (\Service_User::verify_session() !== true) return Msg::no_login(); + + // prompt generation & response handling + $result = Widget_Question_Generator::generate_from_prompt($prompt); + if ( ! $result instanceof Msg && is_string($result)) + { + return [ + 'success' => true, + 'response' => $result + ]; + } + else + { + \Log::error(print_r($result, true)); + return $result; + } + } + /** * Gets the question with the given QID or an array of questions * with the given ids (passed as an array) diff --git a/fuel/app/classes/materia/fuel/core/cookie.php b/fuel/app/classes/materia/fuel/core/cookie.php deleted file mode 100644 index e835cf093..000000000 --- a/fuel/app/classes/materia/fuel/core/cookie.php +++ /dev/null @@ -1,97 +0,0 @@ - 0 ? $expiration + time() : 0; - - // make sure same_site isn't None when cookie isn't secure - if(!$secure && $same_site === 'None') $same_site = 'Strict'; - - //setcookie readily supports SameSite in 7.3 and up, big hacks necessary any earlier than that - if( version_compare(phpversion(), '7.3', '<')) - { - return setcookie($name, $value, $expiration, $path.'; SameSite='.$same_site, $domain, $secure, $http_only); - } - else - { - $cookie_options = [ - 'expires' => $expiration, - 'path' => $path, - 'domain' => $domain, - 'secure' => $secure, - 'httponly' => $http_only, - 'samesite' => $same_site - ]; - return setcookie($name, $value, $cookie_options); - } - } - - /** - * Deletes a cookie by making the value null and expiring it. - * - * Cookie::delete('theme'); - * - * @param string $name cookie name - * @param string $path path of the cookie - * @param string $domain domain of the cookie - * @param boolean $secure if true, the cookie should only be transmitted over a secure HTTPS connection - * @param boolean $http_only if true, the cookie will be made accessible only through the HTTP protocol - * @param string $same_site SameSite value for the cookie - * @return boolean - * @uses static::set - */ - public static function delete($name, $path = null, $domain = null, $secure = null, $http_only = null, $same_site = 'None') - { - // In case $same_site is an empty string, update its value to 'None', as intended - if ( ! $same_site) $same_site = 'None'; - - // Remove the cookie - unset($_COOKIE[$name]); - - // Nullify the cookie and make it expire - return static::set($name, false, -86400, $path, $domain, $secure, $http_only, $same_site); - } -} diff --git a/fuel/app/classes/materia/score/module.php b/fuel/app/classes/materia/score/module.php index 1fe453cde..554464ff5 100644 --- a/fuel/app/classes/materia/score/module.php +++ b/fuel/app/classes/materia/score/module.php @@ -96,7 +96,8 @@ public function validate_scores($timestamp=false) // Check for attempt limit prior to submission, but only for actual plays (not previews) if ($this->play_id != -1) { - $attempts_used = count(\Materia\Score_Manager::get_instance_score_history($this->inst->id, $this->play->context_id)); + $semester = Semester::get_current_semester(); + $attempts_used = count(\Materia\Score_Manager::get_instance_score_history($this->inst->id, $this->play->context_id, $semester)); if ($this->inst->attempts != -1 && $attempts_used >= $this->inst->attempts) { throw new Score_Exception('Attempt Limit Met', 'You have already met the attempt limit for this widget and cannot submit additional scores.'); diff --git a/fuel/app/classes/materia/widget.php b/fuel/app/classes/materia/widget.php index 6014a53ad..cdd36183a 100644 --- a/fuel/app/classes/materia/widget.php +++ b/fuel/app/classes/materia/widget.php @@ -4,33 +4,35 @@ class Widget { - public $clean_name = ''; - public $creator = ''; - public $created_at = 0; - public $dir = ''; - public $flash_version = 0; - public $api_version = 0; - public $height = 0; - public $id = 0; - public $is_answer_encrypted = true; - public $in_catalog = true; - public $is_editable = true; - public $is_playable = true; - public $is_qset_encrypted = true; - public $is_scalable = 0; - public $is_scorable = true; - public $is_storage_enabled = false; - public $package_hash = ''; - public $meta_data = null; - public $name = ''; - public $player = ''; - public $question_types = ''; - public $restrict_publish = false; - public $score_module = 'base'; - public $score_screen = ''; - public $width = 0; - public $creator_guide = ''; - public $player_guide = ''; + public $clean_name = ''; + public $creator = ''; + public $created_at = 0; + public $dir = ''; + public $flash_version = 0; + public $api_version = 0; + public $height = 0; + public $id = 0; + public $is_answer_encrypted = true; + public $in_catalog = true; + public $is_editable = true; + public $is_playable = true; + public $is_qset_encrypted = true; + public $is_scalable = 0; + public $is_scorable = true; + public $is_storage_enabled = false; + public $is_generable = false; + public $uses_prompt_generation = false; + public $package_hash = ''; + public $meta_data = null; + public $name = ''; + public $player = ''; + public $question_types = ''; + public $restrict_publish = false; + public $score_module = 'base'; + public $score_screen = ''; + public $width = 0; + public $creator_guide = ''; + public $player_guide = ''; public const PATHS_PLAYDATA = '_exports'.DS.'playdata_exporters.php'; public const PATHS_SCOREMOD = '_score-modules'.DS.'score_module.php'; @@ -89,31 +91,33 @@ public function get($id_or_clean_name) // -------------- INIT OBJECT --------------- $this->__construct([ - 'clean_name' => $w['clean_name'], - 'created_at' => $w['created_at'], - 'creator' => $w['creator'], - 'is_answer_encrypted' => $w['is_answer_encrypted'], - 'is_qset_encrypted' => $w['is_qset_encrypted'], - 'flash_version' => $w['flash_version'], - 'api_version' => $w['api_version'], - 'height' => $w['height'], - 'id' => $w['id'], - 'in_catalog' => $w['in_catalog'], - 'is_editable' => $w['is_editable'], - 'name' => $w['name'], - 'is_playable' => $w['is_playable'], - 'player' => $w['player'], - 'is_scorable' => $w['is_scorable'], - 'is_scalable' => $w['is_scalable'], - 'score_module' => $w['score_module'], - 'score_screen' => $w['score_screen'], - 'restrict_publish' => $w['restrict_publish'], - 'is_storage_enabled' => $w['is_storage_enabled'], - 'package_hash' => $w['package_hash'], - 'width' => $w['width'], - 'creator_guide' => $w['creator_guide'], - 'player_guide' => $w['player_guide'], - 'meta_data' => static::db_get_metadata($w['id']), + 'clean_name' => $w['clean_name'], + 'created_at' => $w['created_at'], + 'creator' => $w['creator'], + 'is_answer_encrypted' => $w['is_answer_encrypted'], + 'is_qset_encrypted' => $w['is_qset_encrypted'], + 'flash_version' => $w['flash_version'], + 'api_version' => $w['api_version'], + 'height' => $w['height'], + 'id' => $w['id'], + 'in_catalog' => $w['in_catalog'], + 'is_editable' => $w['is_editable'], + 'name' => $w['name'], + 'is_playable' => $w['is_playable'], + 'player' => $w['player'], + 'is_scorable' => $w['is_scorable'], + 'is_scalable' => $w['is_scalable'], + 'score_module' => $w['score_module'], + 'score_screen' => $w['score_screen'], + 'restrict_publish' => $w['restrict_publish'], + 'is_storage_enabled' => $w['is_storage_enabled'], + 'is_generable' => $w['is_generable'], + 'uses_prompt_generation' => $w['uses_prompt_generation'], + 'package_hash' => $w['package_hash'], + 'width' => $w['width'], + 'creator_guide' => $w['creator_guide'], + 'player_guide' => $w['player_guide'], + 'meta_data' => static::db_get_metadata($w['id']), ]); // if creator is empty or set to 'default', use the default creator @@ -121,6 +125,19 @@ public function get($id_or_clean_name) { $this->creator = \Config::get('materia.urls.static').'default-creator/creator.html'; } + + // check if ai generation is available and adjust appropriate fields + if ( ! \Service_User::verify_session('basic_author')) + { + $this->is_generable = '0'; + $this->uses_prompt_generation = '0'; + } + else + { + $this->is_generable = $this->is_generable == '1' && Widget_Question_Generator::is_enabled() ? '1' : '0'; + $this->uses_prompt_generation = $this->uses_prompt_generation == '1' && Widget_Question_Generator::is_enabled() ? '1' : '0'; + } + return true; } @@ -143,6 +160,7 @@ private static function db_get_metadata($id) # multiple items with these keys will be placed in an array case 'features': case 'supported_data': + case 'generation_prompt': case 'playdata_exporters': if ( ! isset($meta_data[$name])) $meta_data[$name] = []; // initialize if needed $meta_data[$name][] = $value; @@ -175,6 +193,14 @@ public function get_property($prop) ->where('name', $prop) ->execute()[0]['value']; } + + if ($prop == 'is_generable' || $prop == 'uses_prompt_generation') + { + if ( ! \Service_User::verify_session('basic_author')) return '0'; + elseif ( Widget_Question_Generator::is_enabled() && $val == '1') return '1'; + else return '0'; + } + return $val; } diff --git a/fuel/app/classes/materia/widget/asset.php b/fuel/app/classes/materia/widget/asset.php index 0618fbe05..f63bd71a2 100644 --- a/fuel/app/classes/materia/widget/asset.php +++ b/fuel/app/classes/materia/widget/asset.php @@ -347,7 +347,19 @@ protected function build_size(string $size): string break; } - $this->_storage_driver->lock_for_processing($this->id, $size); + // object locking is unnecessary with s3 + $driver = \Config::get('materia.asset_storage_driver', 'db'); + if ($driver != 's3') + { + try { + // lock the original asset so we can process it + $this->_storage_driver->lock_for_processing($this->id, 'original'); + } catch (\Throwable $e) + { + \LOG::error($e); + throw($e); + } + } // get the original file $original_asset_path = $this->copy_asset_to_temp_file($this->id, 'original'); @@ -382,10 +394,20 @@ protected function build_size(string $size): string throw($e); } - $this->_storage_driver->store($this, $resized_file_path, $size); + try { + // store the resized asset in s3 or wherever + $this->_storage_driver->store($this, $resized_file_path, $size); - // update asset_data - $this->_storage_driver->unlock_for_processing($this->id, $size); + // unlock original asset + if ($driver != 's3') + { + $this->_storage_driver->unlock_for_processing($this->id, 'original'); + } + } catch (\Throwable $e) + { + \LOG::error($e); + throw($e); + } // close the file handles and delete temp files unlink($original_asset_path); @@ -402,6 +424,14 @@ public function upload_asset_data(string $source_asset_path): void $this->_storage_driver->store($this, $source_asset_path, 'original'); } + /** + * Delete an asset of a specific size + */ + public function delete_asset_data(string $size): void + { + $this->_storage_driver->delete($this->id, $size); + } + /** * Copy the binary of an asset of a specific size to a temp file * @param string $id Asset Id diff --git a/fuel/app/classes/materia/widget/asset/manager.php b/fuel/app/classes/materia/widget/asset/manager.php index b83afe352..166867e7f 100644 --- a/fuel/app/classes/materia/widget/asset/manager.php +++ b/fuel/app/classes/materia/widget/asset/manager.php @@ -59,13 +59,15 @@ static public function new_asset_from_file($name, $file_info) Perm_Manager::set_user_object_perms($asset->id, Perm::ASSET, \Model_User::find_current_id(), [Perm::FULL => Perm::ENABLE]); return $asset; } - catch (\OutsideAreaException | InvalidPathException | \FileAccessException $e) + catch (\OutsideAreaException | InvalidPathException | \FileAccessException | \Exception $e) { - trace($e); + \Log::error('Failed to store asset data: '.$e->getMessage()); } // failed, remove the asset $asset->db_remove(); + + throw new \Exception('Failed to store asset data'); } return $asset; diff --git a/fuel/app/classes/materia/widget/asset/storage/s3.php b/fuel/app/classes/materia/widget/asset/storage/s3.php index adc3d403c..4bbf5372d 100644 --- a/fuel/app/classes/materia/widget/asset/storage/s3.php +++ b/fuel/app/classes/materia/widget/asset/storage/s3.php @@ -20,27 +20,166 @@ public static function instance(array $config): Widget_Asset_Storage_Driver } /** - * Create a lock on a specific size of an asset. + * Create a lock on a specific size of an asset for one hour + * Used to prevent multiple requests from using excessive resources. + * For object locking to work, the bucket must have versioning enabled + * @param string $id Asset Id to lock + * @param string $size Size of asset data to lock + */ + public function lock_for_period(string $id, string $size): void + { + $s3 = $this->get_s3_client(); + + try { + $s3->putObjectRetention([ + 'Bucket' => static::$_config['bucket'], + 'Key' => $this->get_key_name($id, $size), + 'Retention' => [ + 'Mode' => 'GOVERNANCE', + 'RetainUntilDate' => new \DateTime('+1 hour'), + ] + ]); + } + catch (\Exception $e) + { + $error_code = ''; + $source = ''; + if (get_class($e) == 'Aws\S3\Exception\S3Exception') + { + $error_code = $e->getAwsErrorCode(); + $source = $e->getAwsErrorMessage(); + } + \Log::error("S3: Failed to lock asset for period {$id} {$size}. {$error_code} {$source}"); + throw new \Exception("S3: Failed to lock asset for period {$id} {$size}. {$error_code} {$source}"); + } + } + + /** + * Get the lock status of a specific size of an asset + * @param string $id Asset Id to lock + * @param string $size Size of asset data to lock + * @return bool True if locked + */ + public function get_lock_retention(string $id, string $size): bool + { + $s3 = $this->get_s3_client(); + + try { + $result = $s3->getObjectRetention([ + 'Bucket' => static::$_config['bucket'], + 'Key' => $this->get_key_name($id, $size) + ]); + return $result['Retention']['Mode'] === 'GOVERNANCE'; // if it's not governance, it's not locked + } + catch (\Exception $e) + { + $error_code = ''; + $source = ''; + if (get_class($e) == 'Aws\S3\Exception\S3Exception') + { + $error_code = $e->getAwsErrorCode(); + $source = $e->getAwsErrorMessage(); + } + \Log::error("S3: Failed to get lock retention status for asset {$id} {$size}. {$error_code} {$source}"); + return false; + } + } + + /** + * Lock a specific size of an asset * Used to prevent multiple requests from using excessive resources. * @param string $id Asset Id to lock * @param string $size Size of asset data to lock */ public function lock_for_processing(string $id, string $size): void { - // @TODO + $s3 = $this->get_s3_client(); + + try { + $s3->putObjectLegalHold([ + 'Bucket' => static::$_config['bucket'], + 'Key' => $this->get_key_name($id, $size), + 'LegalHold' => [ + 'Status' => 'ON', + ] + ]); + } + catch (\Exception $e) + { + $error_code = ''; + $source = ''; + if (get_class($e) == 'Aws\S3\Exception\S3Exception') + { + $error_code = $e->getAwsErrorCode(); + $source = $e->getAwsErrorMessage(); + } + \Log::error("S3: Failed to lock asset for processing {$id} {$size}. {$error_code} {$source}"); + throw new \Exception("S3: Failed to lock asset for processing {$id} {$size}. {$error_code} {$source}"); + } } /** * Unlock a lock made for a specific size of an asset - * Used to prevent multiple requests from using excessive resources. * @param string $id Asset Id to lock * @param string $size Size of asset data to lock */ public function unlock_for_processing(string $id, string $size): void { - // @TODO + $s3 = $this->get_s3_client(); + + try { + $s3->putObjectLegalHold([ + 'Bucket' => static::$_config['bucket'], + 'Key' => $this->get_key_name($id, $size), + 'LegalHold' => [ + 'Status' => 'OFF', + ] + ]); + } catch (\Exception $e) { + $error_code = ''; + $source = ''; + if (get_class($e) == 'Aws\S3\Exception\S3Exception') + { + $error_code = $e->getAwsErrorCode(); + $source = $e->getAwsErrorMessage(); + } + \Log::error("S3: Failed to unlock asset {$id} {$size}. {$error_code} {$source}"); + throw new \Exception("S3: Failed to unlock asset {$id} {$size}. {$error_code} {$source}"); + } + } + + /** + * Get the lock status of a specific size of an asset + * @param string $id Asset Id to lock + * @param string $size Size of asset data to lock + * @return bool True if locked + */ + public function get_lock(string $id, string $size): bool + { + $s3 = $this->get_s3_client(); + + try { + $result = $s3->getObjectLegalHold([ + 'Bucket' => static::$_config['bucket'], + 'Key' => $this->get_key_name($id, $size) + ]); + return $result['LegalHold']['Status'] === 'ON'; + } + catch (\Exception $e) + { + $error_code = ''; + $source = ''; + if (get_class($e) == 'Aws\S3\Exception\S3Exception') + { + $error_code = $e->getAwsErrorCode(); + $source = $e->getAwsErrorMessage(); + } + \Log::error("S3: Failed to get lock status for asset {$id} {$size}. {$error_code} {$source}"); + return false; + } } + /** * Delete asset data. Set size to '*' to delete all. * @param string $id Asset Id of asset data to delete @@ -56,10 +195,22 @@ public function delete(string $id, string $size = '*'): void } else { - $s3->deleteObject([ - 'Bucket' => static::$_config['bucket'], - 'Key' => $this->get_key_name($id, $size), - ]); + try { + $s3->deleteObject([ + 'Bucket' => static::$_config['bucket'], + 'Key' => $this->get_key_name($id, $size), + ]); + } catch (\Exception $e) { + $error_code = ''; + $source = ''; + if (get_class($e) == 'Aws\S3\Exception\S3Exception') + { + $error_code = $e->getAwsErrorCode(); + $source = $e->getAwsErrorMessage(); + } + \Log::error("S3: Failed to delete asset {$id} {$size}. {$error_code} {$source}"); + throw new \Exception("S3: Failed to delete asset {$id} {$size}. {$error_code} {$source}"); + } } } @@ -73,7 +224,22 @@ public function exists(string $id, string $size): bool { $s3 = $this->get_s3_client(); - return $s3->doesObjectExist(static::$_config['bucket'], $this->get_key_name($id, $size)); + try { + return $s3->doesObjectExistV2( + static::$_config['bucket'], + $this->get_key_name($id, $size) + ); + } catch (\Exception $e) { + $error_code = ''; + $source = ''; + if (get_class($e) == 'Aws\S3\Exception\S3Exception') + { + $error_code = $e->getAwsErrorCode(); + $source = $e->getAwsErrorMessage(); + } + \Log::error("S3: Failed to check if asset {$id} {$size} exists. {$error_code} {$source}"); + return false; + } } /** @@ -98,8 +264,17 @@ public function retrieve(string $id, string $size, string $target_file_path): vo ]); } catch (\Exception $e) { - throw new \Exception("Missing asset data for asset: {$id} {$size}"); + $source = ''; + $error_code = ''; + if (get_class($e) == 'Aws\S3\Exception\S3Exception') + { + $error_code = $e->getAwsErrorCode(); + $source = $e->getAwsErrorMessage(); + } + \Log::error("S3: Failed to retrieve asset {$key}. {$error_code} {$source}"); + throw new \Exception("S3: Failed to retrieve asset {$key}. {$error_code} {$source}"); } + } /** @@ -115,16 +290,33 @@ public function store(Widget_Asset $asset, string $image_path, string $size): vo // Force all uploads in development to have the same bucket sub-directory $key = $this->get_key_name($asset->id, $size); - $s3 = $this->get_s3_client(); + \Log::info("Storing asset data in s3: {$key} ({$asset->get_mime_type()})"); + \Log::info("Asset data path: {$image_path}"); + \Log::info("Size: {$size}"); + \Log::info('Bucket: '.static::$_config['bucket']); + \Log::info("Asset file_size: {$asset->file_size}"); - $result = $s3->putObject([ - 'ACL' => 'public-read', - 'Metadata' => ['Content-Type' => $asset->get_mime_type()], - 'Bucket' => static::$_config['bucket'], - 'Key' => $key, - 'SourceFile' => $image_path, - // 'Body' => $image_data, // use instead of SourceFile to send data - ]); + try { + $s3 = $this->get_s3_client(); + + $result = $s3->putObject([ + 'Metadata' => ['Content-Type' => $asset->get_mime_type()], + 'Bucket' => static::$_config['bucket'], + 'Key' => $key, + 'SourceFile' => $image_path, + // 'Body' => $image_data, // use instead of SourceFile to send data + ]); + } catch (\Exception $e) { + $error_code = ''; + $source = ''; + if (get_class($e) == 'Aws\S3\Exception\S3Exception') + { + $error_code = $e->getAwsErrorCode(); + $source = $e->getAwsErrorMessage(); + } + \Log::error("S3: Failed to store asset {$key}. {$error_code} {$source}"); + throw new \Exception("S3: Failed to store asset {$key}. {$error_code} {$source}"); + } } /** @@ -135,8 +327,7 @@ public function store(Widget_Asset $asset, string $image_path, string $size): vo */ protected function get_key_name(string $id, string $size): string { - $key = (static::$_config['subdir'] ? static::$_config['subdir'].'/' : '').$id; - if ($size !== 'original') $key .= "/{$size}"; + $key = (static::$_config['subdir'] ? static::$_config['subdir'].DS : '')."{$id}_{$size}"; return $key; } @@ -149,22 +340,49 @@ protected function get_s3_client(): \Aws\S3\S3Client if (static::$_s3_client) return static::$_s3_client; $config = [ - 'endpoint' => '', - 'region' => static::$_config['region'], - 'version' => 'latest', - 'credentials' => [ - 'key' => static::$_config['key'], - 'secret' => static::$_config['secret_key'], + 'region' => static::$_config['region'], + 'force_path_style' => static::$_config['force_path_style'] ?? false, + 'version' => 'latest', + 'credentials' => [ + 'key' => static::$_config['key'], + 'secret' => static::$_config['secret_key'], + 'token' => static::$_config['token'] ?? null, ] ]; - // should we use a mock endpoint for testing? - if (static::$_config['endpoint'] !== false) + // endpoint config only required for fakes3 - the param is not required for actual S3 on AWS + if (\Config::get('materia.asset_storage.s3.fakes3_enabled')) $config['endpoint'] = static::$_config['endpoint'] ?? ''; + + // configure credentials, depending on whether we're providing them from env or Amazon's IMDSv2 service + // imds is HIGHLY recommended for prod usage on AWS. Credentials are sourced from the EC2 instance's IAM role, and the credential provider handles rotation + if (static::$_config['credential_provider'] == 'imds') { - $config['endpoint'] = static::$_config['endpoint']; + $provider = \Aws\Credentials\CredentialProvider::defaultProvider(); + $config['credentials'] = $provider; } + elseif (static::$_config['credential_provider'] == 'env') + { + $config['credentials'] = [ + 'key' => static::$_config['key'], + 'secret' => static::$_config['secret_key'], + 'token' => static::$_config['token'] ?? null, + ]; + } + else throw new \Exception('S3: Failed to determine credential provider. Did you set the appropriate environment variable?'); - static::$_s3_client = new \Aws\S3\S3Client($config); + try { + static::$_s3_client = new \Aws\S3\S3Client($config); + } catch (\Exception $e) { + $source = ''; + $error_code = ''; + if (get_class($e) == 'Aws\S3\Exception\S3Exception') + { + $error_code = $e->getAwsErrorCode(); + $source = $e->getAwsErrorMessage(); + } + \Log::error("S3: Failed to create S3 client. {$error_code} {$source}"); + throw new \Exception("S3: Failed to create S3 client. {$error_code} {$source}"); + } return static::$_s3_client; } diff --git a/fuel/app/classes/materia/widget/installer.php b/fuel/app/classes/materia/widget/installer.php index 8e4f9d54d..5f6f92fa4 100644 --- a/fuel/app/classes/materia/widget/installer.php +++ b/fuel/app/classes/materia/widget/installer.php @@ -534,28 +534,30 @@ public static function generate_install_params(array $manifest_data, string $pac $clean_name = \Materia\Widget::make_clean_name($manifest_data['general']['name']); $package_hash = md5_file($package_file); $params = [ - 'name' => $manifest_data['general']['name'], - 'created_at' => time(), - 'flash_version' => $manifest_data['files']['flash_version'], - 'height' => $manifest_data['general']['height'], - 'width' => $manifest_data['general']['width'], - 'restrict_publish' => isset($manifest_data['general']['restrict_publish']) ? Util_Validator::cast_to_bool_enum($manifest_data['general']['restrict_publish']) : '0', - 'is_qset_encrypted' => Util_Validator::cast_to_bool_enum($manifest_data['general']['is_qset_encrypted']), - 'is_answer_encrypted' => Util_Validator::cast_to_bool_enum($manifest_data['general']['is_answer_encrypted']), - 'is_storage_enabled' => Util_Validator::cast_to_bool_enum($manifest_data['general']['is_storage_enabled']), - 'is_playable' => Util_Validator::cast_to_bool_enum($manifest_data['general']['is_playable']), - 'is_editable' => Util_Validator::cast_to_bool_enum($manifest_data['general']['is_editable']), - 'is_scorable' => Util_Validator::cast_to_bool_enum($manifest_data['score']['is_scorable']), - 'in_catalog' => Util_Validator::cast_to_bool_enum($manifest_data['general']['in_catalog']), - 'clean_name' => $clean_name, - 'api_version' => (string)(int)$manifest_data['general']['api_version'], - 'package_hash' => $package_hash, - 'score_module' => $manifest_data['score']['score_module'], - 'creator' => isset($manifest_data['files']['creator']) ? $manifest_data['files']['creator'] : '', - 'player' => isset($manifest_data['files']['player']) ? $manifest_data['files']['player'] : '' , - 'score_screen' => isset($manifest_data['score']['score_screen']) ? $manifest_data['score']['score_screen'] : '', - 'creator_guide' => isset($manifest_data['files']['creator_guide']) ? $manifest_data['files']['creator_guide'] : '', - 'player_guide' => isset($manifest_data['files']['player_guide']) ? $manifest_data['files']['player_guide'] : '' + 'name' => $manifest_data['general']['name'], + 'created_at' => time(), + 'flash_version' => $manifest_data['files']['flash_version'], + 'height' => $manifest_data['general']['height'], + 'width' => $manifest_data['general']['width'], + 'restrict_publish' => isset($manifest_data['general']['restrict_publish']) ? Util_Validator::cast_to_bool_enum($manifest_data['general']['restrict_publish']) : '0', + 'is_qset_encrypted' => Util_Validator::cast_to_bool_enum($manifest_data['general']['is_qset_encrypted']), + 'is_answer_encrypted' => Util_Validator::cast_to_bool_enum($manifest_data['general']['is_answer_encrypted']), + 'is_storage_enabled' => Util_Validator::cast_to_bool_enum($manifest_data['general']['is_storage_enabled']), + 'is_playable' => Util_Validator::cast_to_bool_enum($manifest_data['general']['is_playable']), + 'is_editable' => Util_Validator::cast_to_bool_enum($manifest_data['general']['is_editable']), + 'is_scorable' => Util_Validator::cast_to_bool_enum($manifest_data['score']['is_scorable']), + 'in_catalog' => Util_Validator::cast_to_bool_enum($manifest_data['general']['in_catalog']), + 'is_generable' => isset($manifest_data['general']['is_generable']) ? Util_Validator::cast_to_bool_enum($manifest_data['general']['is_generable']) : '0', + 'uses_prompt_generation' => isset($manifest_data['general']['uses_prompt_generation']) ? Util_Validator::cast_to_bool_enum($manifest_data['general']['uses_prompt_generation']) : '0', + 'clean_name' => $clean_name, + 'api_version' => (string)(int)$manifest_data['general']['api_version'], + 'package_hash' => $package_hash, + 'score_module' => $manifest_data['score']['score_module'], + 'creator' => isset($manifest_data['files']['creator']) ? $manifest_data['files']['creator'] : '', + 'player' => isset($manifest_data['files']['player']) ? $manifest_data['files']['player'] : '' , + 'score_screen' => isset($manifest_data['score']['score_screen']) ? $manifest_data['score']['score_screen'] : '', + 'creator_guide' => isset($manifest_data['files']['creator_guide']) ? $manifest_data['files']['creator_guide'] : '', + 'player_guide' => isset($manifest_data['files']['player_guide']) ? $manifest_data['files']['player_guide'] : '' ]; return $params; } diff --git a/fuel/app/classes/materia/widget/question/generator.php b/fuel/app/classes/materia/widget/question/generator.php new file mode 100644 index 000000000..16cdfd193 --- /dev/null +++ b/fuel/app/classes/materia/widget/question/generator.php @@ -0,0 +1,457 @@ +withBaseUri($endpoint) + ->withHttpHeader('api-key', $api_key) + ->withQueryParam('api-version', $api_version) + ->make(); + } + catch (\Exception $e) + { + \Log::error('GENERATION ERROR: error in initializing openAI client'); + \Log::error($e); + return null; + } + } + elseif (\Config::get('materia.ai_generation.provider') == 'openai') + { + $api_key = \Config::get('materia.ai_generation.api_key'); + + if (empty($api_key)) + { + \Log::error('OpenAI Platform question generation configs missing.'); + return null; + } + + self::$client = \OpenAI::client($api_key); + } + else + { + \Log::error('GENERATION ERROR: Question generation provider config invalid.'); + return null; + } + } + return self::$client; + } + + /** + * Quick reference method to determine whether question generation is enabled. + * + * @return bool Returns true if the widget generator is enabled, false otherwise. + */ + public static function is_enabled() + { + return ! empty(\Config::get('materia.ai_generation.enabled')); + } + + /** + * Submits a prompt to the configured question generation provider. + * + * @param string $prompt The prompt for the query. + * @return object The result of the query. + */ + public static function query($prompt, $format='json') + { + $client = static::get_client(); + if (empty($client)) return Msg::failure('Failed to initialize generation client.'); + + $params = [ + 'messages' => [ + ['role' => 'user', 'content' => $prompt] + ], + 'max_tokens' => 16000, + 'frequency_penalty' => 0, // 0 to 1 + 'presence_penalty' => 0, // 0 to 1 + 'temperature' => 1, // 0 to 1 + 'top_p' => 1, // 0 to 1 + ]; + + if ( ! empty(\Config::get('materia.ai_generation.model'))) $params['model'] = \Config::get('materia.ai_generation.model'); + if ($format == 'json') $params['response_format'] = (object) ['type' => 'json_object']; + + return $client->chat()->create($params); + } + + /** + * Generates a text response based on the provided prompt. + * + * @param string $prompt The prompt to send to the LLM. + * @return string The generated response. + */ + static public function generate_from_prompt($prompt) + { + if ( ! self::is_enabled()) return Msg::failure('Question generation is not enabled.'); + if (empty($prompt) || strlen($prompt) > 10000) return Msg::invalid_input('Prompt text length invalid.'); + + try + { + $result = self::query($prompt, 'message'); + $response = $result->choices[0]->message->content; + + return $response; + } + catch (\Exception $e) + { + \Log::error('Error generating prompt:'.PHP_EOL + .'Prompt: '.$prompt.PHP_EOL + .'Exception: '.$e->getMessage().PHP_EOL); + + return Msg::failure('Error generating question set.'); + } + } + + /** + * Generate a question set for a widget instance + * + * @param Widget_Instance $inst the instance associated with this request (if present) + * @param Widget $widget the widget engine associated with this request + * @param string $topic the topic to be used as the basis of the generated qset + * @param bool $include_images whether or not to include images in the generated qset + * @param int $num_questions the number of questions to generate within the qset + * @param bool $existing whether to build on an existing qset or generate one from scratch + * @return array returns an array with the generated qset + */ + static public function generate_qset($inst, $widget, $topic, $include_images, $num_questions, $existing) + { + if ( ! self::is_enabled()) return Msg::failure('Question generation is not enabled.'); + + // 'allow images' environment variable overrides whatever the api request sends + if ( empty(\Config::get('materia.ai_generation.allow_images'))) $include_images = false; + + $demo = Widget_Instance_Manager::get($widget->meta_data['demo']); + if ( ! $demo) return Msg::not_found(); + + if ($inst) $instance_name = $inst->name; + $widget_name = $widget->name; + $about = $widget->meta_data['about']; + $qset_version = 1; + + // grab the custom prompt from the widget engine, if it's available + $custom_engine_prompt = isset($widget->meta_data['generation_prompt']) ? $widget->meta_data['generation_prompt'][0] : null; + + // time for logging + $start_time = microtime(true); + $time_elapsed_secs = 0; + + // ********************************** + // prompt assembly + // ********************************** + + // appending new questions to an existing qset. The instance must have been previously saved. + if ($existing) + { + if ( ! $inst) return Msg::invalid_input('Requires a previously saved instance to build from.'); + $inst->get_qset($inst->id); + if ( ! $inst->qset->data) return Msg::failure('No existing question set found.'); + if ($inst->qset->version) $qset_version = $inst->qset->version; + + $qset_text = json_encode($inst->qset->data); + + // non-demo non-image prompt + $text = "{$widget->name} is a 'widget', an interactive piece of educational web content described as: '{$about}'. ". + 'Using the exact same json format of the following question set, without changing any field keys or data types and without changing any of the existing questions, '. + "generate {$num_questions} more questions and add them to the existing question set. ". + "The name of this particular instance of {$widget->name} is {$instance_name} and the new questions must be based on this topic: '{$topic}'. ". + 'Return only the JSON for the resulting question set.'; + + if ($include_images) + { + $text = $text." In every asset or assets object in each new question, add a field titled 'description' ". + "that best describes the image within the answer or question's context, unless otherwise specified later on in this prompt. ". + "Do not generate descriptions that would violate OpenAI's image generation safety system and do not use real names. IDs must be null."; + } + else + { + $text = $text.' Leave the asset field empty or otherwise equivalent to asset fields in questions with no associated asset. IDs must be null.'; + } + + if ($custom_engine_prompt && ! empty($custom_engine_prompt)) + { + $text = $text." Lastly, the following instructions apply to the {$widget->name} widget specifically, and supersede earlier instructions where applicable: {$custom_engine_prompt}"; + } + + $text = $text."\n{$qset_text}"; + } + else // creating a new qset based on the demo. Does not require a previously saved instance + { + // get the qset from the demo instance + if ( ! ($demo_inst = Widget_Instance_Manager::get($widget->meta_data['demo']))) return Msg::not_found('Could not locate demo instance for widget engine.'); + $demo_inst->get_qset($demo_inst->id); + if ( ! $demo_inst->qset) return Msg::not_found('Could not locate demo question set for widget engine.'); + if ($demo_inst->qset->version) $qset_version = $demo_inst->qset->version; + $qset_text = json_encode($demo_inst->qset->data); + + // non-image prompt + $text = "{$widget->name} is a 'widget', an interactive piece of educational web content described as: '{$about}'. ". + "The following is a 'demo' question set for the widget titled {$demo->name}. ". + 'Using the same json format as the demo question set, and without changing any field keys or data types, return only the JSON '. + "for a question set based on this topic: '{$topic}'. Ignore the topic of the demo contents entirely. ". + "Replace the relevant field values with generated values. Generate a total {$num_questions} of questions. ". + 'IDs must be NULL.'; + + // image prompt + if ($include_images) + { + $text = $text." In every asset or assets object in each new question, add a field titled 'description' ". + "that best describes the image within the answer or question's context, unless otherwise specified later on in this prompt. ". + "Do not generate descriptions that would violate OpenAI's image generation safety system and do not use real names. IDs must be null."; + } + else + { + $text = $text.' Asset fields associated with media (image, audio, or video) should be left blank. '. + "For text assets, or if the 'materiaType' of an asset is 'text', create a field titled 'value' ". + 'with the text inside the asset object.'; + } + + if ($custom_engine_prompt && ! empty($custom_engine_prompt)) + { + $text = $text." Lastly, the following instructions apply to the {$widget->name} widget specifically, and supersede earlier instructions where applicable: {$custom_engine_prompt}"; + } + + $text = $text."\n{$qset_text}"; + } + + // send the prompt to to the generative AI provider + try { + $result = self::query($text, 'json'); + + // received the qset - decode the json string from the result + $question_set = json_decode($result->choices[0]->message->content); + \Log::info('Generated question set: '.print_r(json_encode($question_set), true)); + + if (\Config::get('materia.ai_generation.log_stats')) + { + $time_elapsed_secs = microtime(true) - $start_time; + + \Log::debug(PHP_EOL + .'Widget: '.$widget_name.PHP_EOL + .'Date: '.date('Y-m-d H:i:s').PHP_EOL + .'Time to complete (in seconds): '.$time_elapsed_secs.PHP_EOL + .'Number of questions asked to generate: '.$num_questions.PHP_EOL + .'Included images: '.$include_images.PHP_EOL + .'Prompt tokens: '.$result->usage->promptTokens.PHP_EOL + .'Completion tokens: '.$result->usage->completionTokens.PHP_EOL + .'Total tokens: '.$result->usage->totalTokens.PHP_EOL); + } + + } catch (\Exception $e) { + \Log::error('Error generating question set:'.PHP_EOL + .'Widget: '.$widget_name.PHP_EOL + .'Date: '.date('Y-m-d H:i:s').PHP_EOL + .'Time to complete (in seconds): '.$time_elapsed_secs.PHP_EOL + .'Number of questions asked to generate: '.$num_questions.PHP_EOL + .'Error: '.$e->getMessage().PHP_EOL); + + return Msg::failure('Error generating question set.'); + } + + if ($include_images) $question_set = static::generate_images($question_set, $existing); + + return [ + 'qset' => $question_set, + 'version' => $qset_version + ]; + } + + + /** + * Generate images for a question set. + * + * This function generates images for a given question set and existing images. + * + * @param array $question_set The question set for which images need to be generated. + * @return void + */ + static public function generate_images($question_set) + { + // get an array of asset descriptions from the qset + $assets = static::comb_assets($question_set); + + $num_assets = count($assets); + if ($num_assets < 1) return $question_set; + + // the dall-e-2 model can generate multiple images for a single prompt, but those are variations of the same image + // in order to generate images for each individual description, calls must be made concurrently + // this is not ideal - perhaps individual image generation is tied to an api endpoint which is facilitated by the front end + foreach ($assets as $description) + { + // generate image + try { + $client = static::get_client(); + $dalle_result = $client->images()->create([ + 'model' => 'dall-e-2', + 'prompt' => $description, + 'response_format' => 'b64_json', + 'size' => '512x512' // 256x256, 512x512, 1024x1024 + ]); + + } catch (\Exception $e) { + \Log::error('Error generating images: '.$e->getMessage()); + \Log::error('Trace: '.$e->getTraceAsString()); + + return $question_set; + } + + // decode the base64 file data + $file_data = base64_decode($dalle_result->data[0]->b64_json); + + // Create a temporary file to store the binary image contents + $temp_file_path = tempnam(sys_get_temp_dir(), 'dalle_sideload_'); + file_put_contents($temp_file_path, $file_data); + + // copy asset to where files would normally be uploaded to + // this is largely mirrored from sideloading demo assets + $src_area = \File::forge(['basedir' => sys_get_temp_dir()]); // restrict copying from system tmp dir + $mock_upload_file_path = \Config::get('file.dirs.media_uploads').uniqid('sideload_'); + \File::copy($temp_file_path, $mock_upload_file_path, $src_area, 'media'); + + // process the upload and turn it into a file + $upload_info = \File::file_info($mock_upload_file_path, 'media'); + $asset = \Materia\Widget_Asset_Manager::new_asset_from_file(static::string_to_slug($description), $upload_info); + + if ( ! isset($asset->id)) + { + \Log::error('Unable to create asset'); + } + else + { + static::assign_asset($question_set, $description, $asset); + } + } + return $question_set; + } + + /** + * Combines all asset descriptions in a question set into a single array + * @param array $qset The question set array + * @return array The array of asset descriptions + */ + static public function comb_assets($qset) + { + $assets = []; + foreach ($qset as $key => $value) + { + if (is_object($value) || is_array($value)) + { + $value = (array) $value; + if ($key == 'asset' || $key == 'image' || $key == 'audio' || $key == 'video' || $key == 'options') + { + if (key_exists('description', $value) && ! empty($value['description'])) + { + $assets[] = $value['description']; + } + } + if ($key == 'assets') + { + $value = (array) $value; + foreach ($value as $asset) + { + $asset = (array) $asset; + if (key_exists('description', $asset) && ! empty($asset['description'])) + { + $assets[] = $asset['description']; + } + } + } + $assets = array_merge($assets, static::comb_assets($value)); + } + } + return $assets; + } + + /** + * Assigns a generated image asset to a qset based on the image description + * @param array $array The question set + * @param string $description the string used to describe (and generate) the image asset + * @param object $asset the asset object (of type \Materia\Widget_Asset) + * @return bool Returns true if asset was inserted into the question set + */ + static public function assign_asset(&$array, $description, $asset) + { + foreach ($array as $key => &$value) + { + if (is_object($value) || is_array($value)) + { + if ($key == 'asset' || $key == 'image' || $key == 'audio' || $key == 'video') + { + if (isset($value->description) && $value->description == $description) + { + $value->id = $asset->id; + return true; + } + else return false; + } + elseif ($key == 'assets') + { + foreach ($value as &$item) + { + if (isset($item->description) && $item->description == $description) + { + $item->id = $asset->id; + return true; + } + else return false; + } + } + else + { + $result = self::assign_asset($value, $description, $asset); + if ($result == true) return $result; + } + } + } + return false; + } + + // helper function to turn a natural language description into a url-safe and filesystem-safe slug + static public function string_to_slug($string) + { + // Convert the string to lowercase + $string = strtolower($string); + + // Remove non-alphanumeric characters (except spaces) + $string = preg_replace('/[^a-z0-9\s]/', '', $string); + + // Replace spaces with hyphens + $string = str_replace(' ', '-', $string); + + // Trim any leading or trailing hyphens + $string = trim($string, '-'); + + return $string; + } +} \ No newline at end of file diff --git a/fuel/app/config/config.php b/fuel/app/config/config.php index dcc197469..159c7ce41 100644 --- a/fuel/app/config/config.php +++ b/fuel/app/config/config.php @@ -196,7 +196,7 @@ /** * Cookie settings */ - // 'cookie' => array( + 'cookie' => array( // Number of seconds before the cookie expires // 'expiration' => 0, // Restrict the path that the cookie is available to @@ -204,10 +204,10 @@ // Restrict the domain that the cookie is available to // 'domain' => null, // Only transmit cookies over secure connections - // 'secure' => false, + 'secure' => filter_var($_ENV['IS_SERVER_HTTPS'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? true, // Only transmit cookies over HTTP, disabling Javascript access // 'http_only' => false, - // ), + ), /** * Validation settings diff --git a/fuel/app/config/css.php b/fuel/app/config/css.php index a00308cf2..93e077d30 100644 --- a/fuel/app/config/css.php +++ b/fuel/app/config/css.php @@ -38,6 +38,7 @@ $webpack.'css/util-question-import.css', $webpack.'css/question-importer.css', ], + 'qset_generator' => [$webpack.'css/qset-generator.css'], 'questionimport' => [$webpack.'css/question-importer.css'], 'qset_history' => [$webpack.'css/qset-history.css'], 'rollback_dialog' => [$webpack.'css/util-rollback-confirm.css'], diff --git a/fuel/app/config/development/materia.php b/fuel/app/config/development/materia.php index cec4350ad..400fb7d70 100644 --- a/fuel/app/config/development/materia.php +++ b/fuel/app/config/development/materia.php @@ -23,12 +23,32 @@ // Storage driver can be overridden from env here // s3 uses fakes3 on dev - 'asset_storage_driver' => 'file', + 'asset_storage_driver' => $_ENV['ASSET_STORAGE_DRIVER'] ?? 'file', 'asset_storage' => [ - 's3' => [ - 'endpoint' => 'http://fakes3:10001', - 'bucket' => 'fake_bucket', // bucket to store original user uploads + 'file' => [ + 'driver_class' => '\Materia\Widget_Asset_Storage_File', + 'media_dir' => APPPATH.'media'.DS, ], + 'db' => [ + 'driver_class' => '\Materia\Widget_Asset_Storage_Db' + ], + 's3' => ( + (($_ENV['ASSET_STORAGE_DRIVER'] ?? 'file') == 's3') + ? [ + 'driver_class' => '\Materia\Widget_Asset_Storage_S3', + 'credential_provider' => $_ENV['ASSET_STORAGE_S3_CREDENTIAL_PROVIDER'] ?? 'env', // env or imds. Should be set to env for fakes3 + 'endpoint' => $_ENV['ASSET_STORAGE_S3_ENDPOINT'] ?? 'http://fakes3:10001', // set to url for testing endpoint + 'region' => $_ENV['ASSET_STORAGE_S3_REGION'] ?? 'us-east-1', // aws region for bucket + 'bucket' => $_ENV['ASSET_STORAGE_S3_BUCKET'] ?? 'fake_bucket', // bucket to store original user uploads + 'subdir' => $_ENV['ASSET_STORAGE_S3_BASEPATH'] ?? 'media', // OPTIONAL - directory to store original and resized assets + 'secret_key' => $_ENV['AWS_SECRET_ACCESS_KEY'] ?? $_ENV['ASSET_STORAGE_S3_SECRET'] ?? 'SECRET', // aws api secret key + 'key' => $_ENV['AWS_ACCESS_KEY_ID'] ?? $_ENV['ASSET_STORAGE_S3_KEY'] ?? 'KEY', // aws api key + 'token' => $_ENV['AWS_SESSION_TOKEN'] ?? 'TOKEN', // aws session token + 'force_path_style' => $_ENV['ASSET_STORAGE_S3_FORCE_PATH_STYLE'] ?? false, // needed for fakes3 + 'fakes3_enabled' => $_ENV['DEV_ONLY_FAKES3_DISABLED'] ?? true, // using fakes3 unless explicitly disabled + ] + : null + ), ] ]; \ No newline at end of file diff --git a/fuel/app/config/js.php b/fuel/app/config/js.php index f23e1cfb8..ea8a86260 100644 --- a/fuel/app/config/js.php +++ b/fuel/app/config/js.php @@ -32,6 +32,7 @@ '500' => [$webpack.'js/500.js'], 'media' => [$webpack.'js/media.js'], 'qset_history' => [$webpack.'js/qset-history.js'], + 'qset_generator' => [$webpack.'js/qset-generator.js'], 'post_login' => [$webpack.'js/lti-post-login.js'], 'select_item' => [$webpack.'js/lti-select-item.js'], 'open_preview' => [$webpack.'js/lti-open-preview.js'], diff --git a/fuel/app/config/materia.php b/fuel/app/config/materia.php index 0d8204309..30cce8412 100644 --- a/fuel/app/config/materia.php +++ b/fuel/app/config/materia.php @@ -110,15 +110,29 @@ (($_ENV['ASSET_STORAGE_DRIVER'] ?? 'file') == 's3') ? [ 'driver_class' => '\Materia\Widget_Asset_Storage_S3', - 'endpoint' =>$_ENV['ASSET_STORAGE_S3_ENDPOINT'] ?? false, // set to url for testing endpoint - 'region' => $_ENV['ASSET_STORAGE_S3_REGION'] ?? 'us-east-1', // aws region for bucket - 'bucket' => $_ENV['ASSET_STORAGE_S3_BUCKET'], // bucket to store original user uploads - 'subdir' => $_ENV['ASSET_STORAGE_S3_BASEPATH'] ?? 'media', // OPTIONAL - directory to store original and resized assets - 'secret_key' => $_ENV['ASSET_STORAGE_S3_SECRET'], // aws api secret key - 'key' => $_ENV['ASSET_STORAGE_S3_KEY'] ?? 'KEY' // aws api key + 'credential_provider' => $_ENV['ASSET_STORAGE_S3_CREDENTIAL_PROVIDER'] ?? 'env', + 'endpoint' => $_ENV['ASSET_STORAGE_S3_ENDPOINT'] ?? '', // set to url for testing endpoint (Not required for S3 on AWS) + 'region' => $_ENV['ASSET_STORAGE_S3_REGION'] ?? 'us-east-1', // aws region for bucket + 'bucket' => $_ENV['ASSET_STORAGE_S3_BUCKET'] ?? '', // bucket to store original user uploads + 'subdir' => $_ENV['ASSET_STORAGE_S3_BASEPATH'] ?? 'media', // OPTIONAL - directory to store original and resized assets + 'secret_key' => $_ENV['AWS_SECRET_ACCESS_KEY'] ?? $_ENV['ASSET_STORAGE_S3_SECRET'] ?? 'SECRET', // aws api secret key + 'key' => $_ENV['AWS_ACCESS_KEY_ID'] ?? $_ENV['ASSET_STORAGE_S3_KEY'] ?? 'KEY', // aws api key + 'token' => $_ENV['AWS_SESSION_TOKEN'] ?? null, // aws session token + 'fakes3_enabled' => false, // using fakes3 ] : null ), + ], + + 'ai_generation' => [ + 'enabled' => filter_var($_ENV['GENERATION_ENABLED'] ?? false, FILTER_VALIDATE_BOOLEAN), + 'allow_images' => filter_var($_ENV['GENERATION_ALLOW_IMAGES'] ?? false, FILTER_VALIDATE_BOOLEAN), + 'provider' => $_ENV['GENERATION_API_PROVIDER'] ?? '', + 'endpoint' => $_ENV['GENERATION_API_ENDPOINT'] ?? '', + 'api_key' => $_ENV['GENERATION_API_KEY'] ?? '', + 'api_version' => $_ENV['GENERATION_API_VERSION'] ?? '', + 'model' => $_ENV['GENERATION_API_MODEL'] ?? '', + 'log_stats' => filter_var($_ENV['GENERATION_LOG_STATS'] ?? false, FILTER_VALIDATE_BOOLEAN) ] ]; diff --git a/fuel/app/config/session.php b/fuel/app/config/session.php index 8a444e18c..454fccb1b 100644 --- a/fuel/app/config/session.php +++ b/fuel/app/config/session.php @@ -13,4 +13,5 @@ ] ], 'expiration_time' => $_ENV['SESSION_EXPIRATION'] ?? null, + 'cookie_same_site' => 'None', ]; diff --git a/fuel/app/migrations/056_add_is_generable_field_to_widget_table.php b/fuel/app/migrations/056_add_is_generable_field_to_widget_table.php new file mode 100644 index 000000000..555eb43ba --- /dev/null +++ b/fuel/app/migrations/056_add_is_generable_field_to_widget_table.php @@ -0,0 +1,20 @@ + ['constraint' => "'0','1'", 'type' => 'enum', 'default' => '0'], + )); + } + + public function down() + { + \DBUtil::drop_fields('widget', array( + 'is_generable', + )); + } +} diff --git a/fuel/app/migrations/057_add_uses_prompt_generation_field_to_widget_table.php b/fuel/app/migrations/057_add_uses_prompt_generation_field_to_widget_table.php new file mode 100644 index 000000000..0825c4718 --- /dev/null +++ b/fuel/app/migrations/057_add_uses_prompt_generation_field_to_widget_table.php @@ -0,0 +1,20 @@ + ['constraint' => "'0','1'", 'type' => 'enum', 'default' => '0'], + )); + } + + public function down() + { + \DBUtil::drop_fields('widget', array( + 'uses_prompt_generation', + )); + } +} diff --git a/fuel/app/tests/api/v1.php b/fuel/app/tests/api/v1.php index cdd2a709c..05c53bd35 100644 --- a/fuel/app/tests/api/v1.php +++ b/fuel/app/tests/api/v1.php @@ -1132,6 +1132,59 @@ public function test_question_set_get() } } + public function test_question_set_generate() + { + // ======= GENERATION DISABLED ======== + if ( ! \Materia\Widget_Question_Generator::is_enabled()) + { + $output = Api_V1::question_set_generate(null, 1, 'Pixar Films', false, 8, false); + $this->assert_failure_message($output); + } + else + { + // ======= AS NO ONE ======== + $output = Api_V1::question_set_generate(null, 1, 'Pixar Films', false, 8, false); + $this->assert_permission_denied_message($output); + + // ===== AS STUDENT ======= + $this->_as_student(); + $output = Api_V1::question_set_generate(null, 1, 'Pixar Films', false, 8, false); + $this->assert_permission_denied_message($output); + + // ======= AS AUTHOR ======= + // NOTE: We're not going to perform actual question generation, since that would slow tests down considerably and incur costs + // Tests several error boundaries instead + $this->_as_author(); + $output = Api_V1::question_set_generate(null, -1, 'Pixar Films', false, 8, false); + $this->assert_validation_error_message($output); + + try + { + $output = Api_V1::question_set_generate('11111', -1, 'Pixar Films', false, 8, false); + $this->fail('Expected exception HttpNotFoundException not thrown'); + } + catch (\Exception $e) + { + $this->assertInstanceOf('HttpNotFoundException', $e); + } + } + } + + public function test_widget_prompt_generate() + { + if ( ! \Materia\Widget_Question_Generator::is_enabled()) + { + $output = Api_V1::widget_prompt_generate('Provide a background story for Kogneato, the robot mascot of Materia, a platform for educational tools and games.'); + $this->assert_failure_message($output); + } + else + { + // ======= AS NO ONE ======== + $output = Api_V1::widget_prompt_generate('Provide a background story for Kogneato, the robot mascot of Materia, a platform for educational tools and games.'); + $this->assert_invalid_login_message($output); + } + } + public function test_questions_get() { // ======= AS NO ONE ======== @@ -1611,6 +1664,18 @@ protected function assert_invalid_login_message($msg) $this->assertEquals('Invalid Login', $msg->title); } + protected function assert_not_found_message($msg) + { + $this->assertInstanceOf('\Materia\Msg', $msg); + $this->assertEquals('Not Found', $msg->title); + } + + protected function assert_failure_message($msg) + { + $this->assertInstanceOf('\Materia\Msg', $msg); + $this->assertEquals('Action Failed', $msg->title); + } + protected function assert_permission_denied_message($msg) { $this->assertInstanceOf('\Materia\Msg', $msg); diff --git a/fuel/app/tests/widget_source/test_widget/src/install.yaml b/fuel/app/tests/widget_source/test_widget/src/install.yaml index 2ea7afcbd..c28f6718f 100755 --- a/fuel/app/tests/widget_source/test_widget/src/install.yaml +++ b/fuel/app/tests/widget_source/test_widget/src/install.yaml @@ -9,6 +9,8 @@ general: is_storage_enabled: No is_qset_encrypted: No is_answer_encrypted: No + is_generable: No + uses_prompt_generation: No api_version: 2 files: player: player.html diff --git a/fuel/app/tests/widgets/installer.php b/fuel/app/tests/widgets/installer.php index 972055c10..73a641c10 100644 --- a/fuel/app/tests/widgets/installer.php +++ b/fuel/app/tests/widgets/installer.php @@ -15,16 +15,18 @@ public function test_generate_install_params() $manifest_data = [ 'general' => [ - 'name' => 'THIS IS A Name!', - 'height' => 55, - 'width' => 100, - 'is_qset_encrypted' => false, - 'is_answer_encrypted' => true, - 'is_storage_enabled' => '1', - 'is_playable' => '0', - 'is_editable' => 'true', - 'in_catalog' => 'false', - 'api_version' => '2', + 'name' => 'THIS IS A Name!', + 'height' => 55, + 'width' => 100, + 'is_qset_encrypted' => false, + 'is_answer_encrypted' => true, + 'is_storage_enabled' => '1', + 'is_playable' => '0', + 'is_editable' => 'true', + 'in_catalog' => 'false', + 'is_generable' => '0', + 'uses_prompt_generation' => '0', + 'api_version' => '2', ], 'score' => [ 'score_module' => 'scoreModule', @@ -46,6 +48,8 @@ public function test_generate_install_params() 'is_qset_encrypted' => '0', 'is_answer_encrypted' => '1', 'is_storage_enabled' => '1', + 'is_generable' => '0', + 'uses_prompt_generation' => '0', 'is_playable' => '0', 'is_editable' => '0', 'is_scorable' => '1', diff --git a/githooks/pre-commit b/githooks/pre-commit index 42580de52..a43832090 100755 --- a/githooks/pre-commit +++ b/githooks/pre-commit @@ -14,7 +14,7 @@ function execute_and_check_status { return $status } -DCTEST="docker-compose -f docker-compose.yml -f docker-compose.override.test.yml" +DCTEST="docker compose -f docker-compose.yml -f docker-compose.override.test.yml" cd docker echo "Running git pre-commit" diff --git a/package.json b/package.json index 332301731..075be7706 100644 --- a/package.json +++ b/package.json @@ -56,8 +56,8 @@ "lint-staged": "^10.2.11", "mini-css-extract-plugin": "^2.7.2", "nodemon": "^2.0.20", - "react": "^16.13.1", - "react-dom": "^16.13.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", "react-test-renderer": "^17.0.2", "sass": "^1.69.5", "sass-loader": "^13.2.0", diff --git a/public/dist/package.json b/public/dist/package.json index 6ae44d552..8755abeeb 100644 --- a/public/dist/package.json +++ b/public/dist/package.json @@ -18,6 +18,8 @@ "css/qset-history.css", "js/question-importer.js", "css/question-importer.css", + "js/qset-generator.js", + "css/qset-generator.css", "js/guides.js", "css/guides.css", "js/scores.js", diff --git a/src/404.js b/src/404.js index 385397148..89fecdefc 100644 --- a/src/404.js +++ b/src/404.js @@ -1,14 +1,13 @@ import React from 'react' -import ReactDOM from 'react-dom' +import {createRoot} from 'react-dom/client' import { QueryClient, QueryClientProvider, QueryCache } from 'react-query' -import { ReactQueryDevtools } from "react-query/devtools"; import Action404 from './components/404' const queryCache = new QueryCache() export const queryClient = new QueryClient({ queryCache }) -ReactDOM.render( +const root = createRoot(document.getElementById('app')); +root.render( - - , document.getElementById('app')) + ) diff --git a/src/500.js b/src/500.js index f1b3452bf..3d63f9660 100644 --- a/src/500.js +++ b/src/500.js @@ -1,14 +1,13 @@ import React from 'react' -import ReactDOM from 'react-dom' +import {createRoot} from 'react-dom/client' import { QueryClient, QueryClientProvider, QueryCache } from 'react-query' -import { ReactQueryDevtools } from "react-query/devtools"; import Action500 from './components/500' const queryCache = new QueryCache() export const queryClient = new QueryClient({ queryCache }) -ReactDOM.render( +const root = createRoot(document.getElementById('app')); +root.render( - - , document.getElementById('app')) + ) diff --git a/src/catalog.js b/src/catalog.js index f36b1ada3..dff629f0e 100644 --- a/src/catalog.js +++ b/src/catalog.js @@ -1,14 +1,13 @@ import React from 'react' -import ReactDOM from 'react-dom' +import {createRoot} from 'react-dom/client' import { QueryClient, QueryClientProvider, QueryCache } from 'react-query' -import { ReactQueryDevtools } from "react-query/devtools"; import CatalogPage from './components/catalog-page' const queryCache = new QueryCache() export const queryClient = new QueryClient({ queryCache }) -ReactDOM.render( +const root = createRoot(document.getElementById('app')); +root.render( - - , document.getElementById('app')) + ) diff --git a/src/closed.js b/src/closed.js index 512e3eb15..6c37adca6 100644 --- a/src/closed.js +++ b/src/closed.js @@ -1,14 +1,13 @@ import React from 'react' -import ReactDOM from 'react-dom' +import {createRoot} from 'react-dom/client' import { QueryClient, QueryClientProvider, QueryCache } from 'react-query' -import { ReactQueryDevtools } from "react-query/devtools"; import Closed from './components/closed' const queryCache = new QueryCache() export const queryClient = new QueryClient({ queryCache }) -ReactDOM.render( +const root = createRoot(document.getElementById('app')); +root.render( - - , document.getElementById('app')) + ) diff --git a/src/components/closed.jsx b/src/components/closed.jsx index 86679842f..cd6a7ae45 100644 --- a/src/components/closed.jsx +++ b/src/components/closed.jsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'; import Header from './header' import Summary from './widget-summary' import './login-page.scss' +import EmbedFooter from './widget-embed-footer'; const Closed = () => { @@ -45,6 +46,7 @@ const Closed = () => {

{ state.summary }

{ state.description }

+ diff --git a/src/components/detail-carousel.jsx b/src/components/detail-carousel.jsx index c7ed6a81a..3558188d7 100644 --- a/src/components/detail-carousel.jsx +++ b/src/components/detail-carousel.jsx @@ -248,6 +248,7 @@ const DetailCarousel = ({widget, widgetHeight=''}) => { const snapToImage = (fast=false) => { const _pics = picScrollerRef.current + if(!_pics) return false //with react 18 rendering order is different, null check needed const i = selectionData.selectedImage.num if (_pics.children.length && _pics.children[i]) { const _offset = _pics.children[i].offsetLeft * -1 diff --git a/src/components/embedded-only.jsx b/src/components/embedded-only.jsx index 4caf60eec..d176a52f6 100644 --- a/src/components/embedded-only.jsx +++ b/src/components/embedded-only.jsx @@ -1,18 +1,21 @@ import React from 'react'; import Summary from './widget-summary' +import EmbedFooter from './widget-embed-footer'; const EmbeddedOnly = () => { return ( -
-
- +
+
+ -
-

Not Playable Here

- Your instructor has not made this widget available outside of the LMS. -
-
-
+
+

Not Playable Here

+ Your instructor has not made this widget available outside of the LMS. +
+ + +
+
) } diff --git a/src/components/header.jsx b/src/components/header.jsx index a76bef1bb..4bd8bf072 100644 --- a/src/components/header.jsx +++ b/src/components/header.jsx @@ -12,7 +12,8 @@ const Header = ({ const { data: verified} = useQuery({ queryKey: 'isLoggedIn', queryFn: apiAuthorVerify, - staleTime: Infinity + staleTime: Infinity, + retry: false }) const { data: user, isLoading: userLoading} = useQuery({ queryKey: 'user', diff --git a/src/components/hooks/useQuestionGeneration.jsx b/src/components/hooks/useQuestionGeneration.jsx new file mode 100644 index 000000000..243b6d2a3 --- /dev/null +++ b/src/components/hooks/useQuestionGeneration.jsx @@ -0,0 +1,16 @@ +import { useMutation } from "react-query"; +import { apiGenerateQset } from "../../util/api"; + +export default function useQuestionGeneration() { + return useMutation( + apiGenerateQset, + { + onSuccess: (qset, variables) => { + variables.successFunc(qset) + }, + onError: (error, variables, context) => { + variables.errorFunc(error) + } + } + ) +} diff --git a/src/components/login-page.jsx b/src/components/login-page.jsx index 4f9ebf07b..541bf9143 100644 --- a/src/components/login-page.jsx +++ b/src/components/login-page.jsx @@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react' import Header from './header' import Summary from './widget-summary' import './login-page.scss' +import EmbedFooter from './widget-embed-footer' const LoginPage = () => { @@ -107,6 +108,7 @@ const LoginPage = () => { : '' } + { state.context && state.context == 'widget' ? : ''} diff --git a/src/components/login-page.scss b/src/components/login-page.scss index 3c1b7cfc4..ebafcf27b 100644 --- a/src/components/login-page.scss +++ b/src/components/login-page.scss @@ -3,5 +3,5 @@ span.subtitle { font-size: .7em; - font-weight: 300; + font-weight: 400; } diff --git a/src/components/media-importer.jsx b/src/components/media-importer.jsx index afa0cfafb..d8098dfd1 100644 --- a/src/components/media-importer.jsx +++ b/src/components/media-importer.jsx @@ -229,6 +229,7 @@ const MediaImporter = () => { setSelectedAsset(res.id) } else { setErrorState('Something went wrong with uploading your file.') + // _onCancel() // uncomment to close the modal on error return } } diff --git a/src/components/my-widgets-collaborate-dialog.jsx b/src/components/my-widgets-collaborate-dialog.jsx index 7362a2cd6..37e9ab00c 100644 --- a/src/components/my-widgets-collaborate-dialog.jsx +++ b/src/components/my-widgets-collaborate-dialog.jsx @@ -36,6 +36,7 @@ const MyWidgetsCollaborateDialog = ({onClose, inst, myPerms, otherUserPerms, set queryFn: () => apiGetUsers(Array.from(otherUserPerms.keys())), staleTime: Infinity, placeholderData: {}, + retry: false, onSuccess: (data) => { setCollabUsers({...collabUsers, ...data}) }, diff --git a/src/components/my-widgets-page.jsx b/src/components/my-widgets-page.jsx index ddeeb91c0..1fe6bc3b1 100644 --- a/src/components/my-widgets-page.jsx +++ b/src/components/my-widgets-page.jsx @@ -58,6 +58,7 @@ const MyWidgetsPage = () => { queryKey: 'user', queryFn: apiGetUser, staleTime: Infinity, + retry: false, onError: (err) => { if (err.message == "Invalid Login") { @@ -80,6 +81,7 @@ const MyWidgetsPage = () => { enabled: !!state.selectedInst && !!state.selectedInst.id && state.selectedInst?.id !== undefined, placeholderData: null, staleTime: Infinity, + retry: false, onError: (err) => { if (err.message == "Invalid Login") { setInvalidLogin(true) diff --git a/src/components/my-widgets-score-semester-individual.jsx b/src/components/my-widgets-score-semester-individual.jsx index 84dade5b2..15ae8fb30 100644 --- a/src/components/my-widgets-score-semester-individual.jsx +++ b/src/components/my-widgets-score-semester-individual.jsx @@ -38,6 +38,7 @@ const MyWidgetScoreSemesterIndividual = ({ semester, instId, setInvalidLogin }) enabled: !!instId && !!semester && !!semester.term && !!semester.year, placeholderData: [], refetchOnWindowFocus: false, + retry: false, onSuccess: (result) => { if (page <= result?.total_num_pages) setPage(page + 1) if (result && result.pagination) { diff --git a/src/components/my-widgets-score-semester-storage.jsx b/src/components/my-widgets-score-semester-storage.jsx index 1315fa600..8169325b9 100644 --- a/src/components/my-widgets-score-semester-storage.jsx +++ b/src/components/my-widgets-score-semester-storage.jsx @@ -36,6 +36,7 @@ const MyWidgetScoreSemesterStorage = ({semester, instId, setInvalidLogin}) => { enabled: !!instId, staleTime: Infinity, placeholderData: {}, + retry: false, onError: (err) => { if (err.message == "Invalid Login") { setInvalidLogin(true); diff --git a/src/components/my-widgets-scores.jsx b/src/components/my-widgets-scores.jsx index e1badb875..1a69baab8 100644 --- a/src/components/my-widgets-scores.jsx +++ b/src/components/my-widgets-scores.jsx @@ -20,6 +20,7 @@ const MyWidgetsScores = ({inst, beardMode, setInvalidLogin}) => { enabled: !!inst && !!inst.id, staleTime: Infinity, placeholderData: [], + retry: false, onError: (err) => { if (err.message == "Invalid Login") { setInvalidLogin(true); diff --git a/src/components/my-widgets-selected-instance.jsx b/src/components/my-widgets-selected-instance.jsx index f843542d7..3d170e2f0 100644 --- a/src/components/my-widgets-selected-instance.jsx +++ b/src/components/my-widgets-selected-instance.jsx @@ -80,6 +80,7 @@ const MyWidgetSelectedInstance = ({ placeholderData: null, enabled: !!inst.id, staleTime: Infinity, + retry: false, onError: (err) => { if (err.message == "Invalid Login") { diff --git a/src/components/my-widgets-settings-dialog.jsx b/src/components/my-widgets-settings-dialog.jsx index a30471b1f..e65ecee6f 100644 --- a/src/components/my-widgets-settings-dialog.jsx +++ b/src/components/my-widgets-settings-dialog.jsx @@ -84,6 +84,7 @@ const MyWidgetsSettingsDialog = ({ onClose, inst, currentUser, otherUserPerms, o placeholderData: {}, enabled: !!otherUserPerms && Array.from(otherUserPerms.keys())?.length > 0, staleTime: Infinity, + retry: false, onError: (err) => { console.error(`Error: ${err.message}`); if (err.message == "Invalid Login") { diff --git a/src/components/no-attempts.jsx b/src/components/no-attempts.jsx index e4a3faf80..562e35cdb 100644 --- a/src/components/no-attempts.jsx +++ b/src/components/no-attempts.jsx @@ -1,21 +1,22 @@ import React, { useState, useEffect} from 'react' import Summary from './widget-summary' import Header from './header' +import EmbedFooter from './widget-embed-footer' const NoAttempts = () => { - const [attempts, setAttempts] = useState(null) - const [scoresPath, setScoresPath] = useState(null) + const [attempts, setAttempts] = useState(null) + const [scoresPath, setScoresPath] = useState(null) - useEffect(() => { - waitForWindow().then(() => { - const scoresPath = `/scores${window.IS_EMBEDDED ? '/embed' : ''}/${window.WIDGET_ID}`; + useEffect(() => { + waitForWindow().then(() => { + const scoresPath = `/scores${window.IS_EMBEDDED ? '/embed' : ''}/${window.WIDGET_ID}`; - setScoresPath(scoresPath); - setAttempts(window.ATTEMPTS) - }) - }, []) + setScoresPath(scoresPath); + setAttempts(window.ATTEMPTS) + }) +}, []) - const waitForWindow = async () => { +const waitForWindow = async () => { while(!window.hasOwnProperty('WIDGET_ID') && !window.hasOwnProperty('IS_EMBEDDED') && !window.hasOwnProperty('ATTEMPTS')) { @@ -23,31 +24,33 @@ const NoAttempts = () => { } } - let bodyRender = null - if (!!attempts) { - bodyRender = ( -
-
- - -
-

No remaining attempts

- You've used all { attempts } available attempts. -

- Review previous scores -

-
-
-
- ) - } - - return ( - <> -
- { bodyRender } - - ) + let bodyRender = null + if (!!attempts) { + bodyRender = ( +
+
+ + +
+

No remaining attempts

+ You've used all { attempts } available attempts. +

+ Review previous scores +

+
+ + +
+
+ ) +} + +return ( + <> +
+ { bodyRender } + +) } export default NoAttempts diff --git a/src/components/notifications.jsx b/src/components/notifications.jsx index d66b3cd2f..250bc35f5 100644 --- a/src/components/notifications.jsx +++ b/src/components/notifications.jsx @@ -5,17 +5,17 @@ import useDeleteNotification from './hooks/useDeleteNotification' import setUserInstancePerms from './hooks/useSetUserInstancePerms' const Notifications = (user) => { - const [navOpen, setNavOpen] = useState(false); - const [showDeleteBtn, setShowDeleteBtn] = useState(-1); - const deleteNotification = useDeleteNotification() - const queryClient = useQueryClient() - const setUserPerms = setUserInstancePerms() - const numNotifications = useRef(0); - const [errorMsg, setErrorMsg] = useState({ - notif_id: '', - msg: '' - }); - let modalRef = useRef(); + const [navOpen, setNavOpen] = useState(false); + const [showDeleteBtn, setShowDeleteBtn] = useState(-1); + const deleteNotification = useDeleteNotification() + const queryClient = useQueryClient() + const setUserPerms = setUserInstancePerms() + const numNotifications = useRef(0); + const [errorMsg, setErrorMsg] = useState({ + notif_id: '', + msg: '' + }); + let modalRef = useRef(); const { data: notifications} = useQuery({ queryKey: 'notifications', @@ -24,230 +24,231 @@ const Notifications = (user) => { refetchOnMount: false, refetchOnWindowFocus: true, queryFn: apiGetNotifications, - staleTime: Infinity, - onSuccess: (data) => { - numNotifications.current = 0; - if (data && data.length > 0) data.forEach(element => { - if (!element.remove) numNotifications.current++; - }); - }, - onError: (err) => { - if (err.message == "Invalid Login") { - window.location.href = '/users/login' - } else { - console.error(err) - } - } - }) - - // Close notification modal if user clicks outside of it - useEffect(() => { - if (navOpen) - { - const checkIfClickedOutsideModal = e => { - if (modalRef.current && !modalRef.current.contains(e.target) && !e.target.className.includes("noticeClose")) - { - setNavOpen(false); - } - } - document.addEventListener("click", checkIfClickedOutsideModal); - - return () => { - document.removeEventListener("click", checkIfClickedOutsideModal); - } - } - }, [navOpen]) - - const toggleNavOpen = () => - { - setNavOpen(!navOpen); - } - // Sets the index of the hovered notification - // Shows delete button on hover - const showDeleteButton = (index) => - { - setShowDeleteBtn(index); - } - const hideDeleteButton = () => - { - setShowDeleteBtn(-1); - } - - const removeNotification = (index, id = null) => { - let notif = null; - if (index >= 0) notif = notifications[index]; - if (id == null) id = notif.id; - - deleteNotification.mutate({ - notifId: id, - deleteAll: false, - successFunc: () => { - Object.keys(notifications).forEach((key, index) => { - if (notifications[key].id == id) - { - notifications[key].remove = true; - numNotifications.current--; - return; - } - }) - }, - errorFunc: (err) => { - setErrorMsg({notif_id: id, msg: 'Action failed.'}); - } - }); - } - - const removeAllNotifications = () => { - deleteNotification.mutate({ - notifId: '', - deleteAll: true, - successFunc: () => {}, - errorFunc: (err) => {} - }); - } - - const onChangeAccessLevel = (notif, access) => { - if (access != "") - { - document.getElementById(notif.id + '_action_button').className = "action_button notification_action enabled"; - } - } - - const onClickGrantAccess = (notif) => { - let accessLevel = document.getElementById(notif.id + '-access-level').value; - - if (accessLevel == "") - { - return; - } - - const expireTime = null; - - const userPerms = [{ - user_id: notif.from_id, - expiration: expireTime, - perms: { - [accessLevel]: true - }, - }] - setUserPerms.mutate({ - instId: notif.item_id, - permsObj: userPerms, - successFunc: (data) => { - // Redirect to widget - if (!window.location.pathname.includes('my-widgets')) - { - // No idea why this works - // But setting hash after setting pathname would set the hash first and then the pathname in URL - window.location.hash = notif.item_id + '-collab'; - window.location.pathname = '/my-widgets' - } - else - { - queryClient.invalidateQueries(['user-perms', notif.item_id]) - window.location.hash = notif.item_id + '-collab'; - } - - setErrorMsg({notif_id: notif.id, msg: ''}); - - removeNotification(-1, notif.id); - - // Close notifications - setNavOpen(false) - }, - errorFunc: (err) => { - setErrorMsg({notif_id: notif.id, msg: 'Action failed.'}) - } - }) - - - } - - let render = null; - let notificationElements = null; - let notificationIcon = null; - - if (notifications?.length > 0) { - notificationElements = [] - for (let index = notifications.length - 1; index >= 0; index--) - { - const notification = notifications[index]; - // If notification was deleted don't show - if (notification.remove) continue; - - let actionButton = null; - let grantAccessDropdown = null; - if (notification.action == "access_request") - { - grantAccessDropdown =
-

Grant Access

- -
- actionButton = - } - let createdAt = new Date(0); - createdAt.setUTCSeconds(notification.created_at) - let notifRow =
showDeleteButton(index)} - onMouseLeave={hideDeleteButton} - > - -
-
${notification.subject}

`}}>
- { grantAccessDropdown } - { actionButton } -

Sent on {createdAt.toLocaleString()}

-
- {removeNotification(index)}} - /> -

{errorMsg.notif_id == notification.id ? errorMsg.msg : ''}

-
- - notificationIcon = - - - notificationElements.push(notifRow) - } - - // In the case that some notifications were removed, we don't want to render the empty notificationElements - if (notificationElements.length > 0) - { - render = ( -
- { notificationIcon } - { navOpen ? -
-

Messages:

- { notificationElements } - removeAllNotifications()}>Remove all Notifications -
- : <> } -
- ) - } - } - else - { - render = null; - - // Keeping this here in case the empty notification icon gets used - notificationElements =

You have no messages!

- - notificationIcon = - - } - - return render; + staleTime: Infinity, + retry: false, + onSuccess: (data) => { + numNotifications.current = 0; + if (data && data.length > 0) data.forEach(element => { + if (!element.remove) numNotifications.current++; + }); + }, + onError: (err) => { + if (err.message == "Invalid Login") { + window.location.href = '/users/login' + } else { + console.error(err) + } + } + }) + + // Close notification modal if user clicks outside of it + useEffect(() => { + if (navOpen) + { + const checkIfClickedOutsideModal = e => { + if (modalRef.current && !modalRef.current.contains(e.target) && !e.target.className.includes("noticeClose")) + { + setNavOpen(false); + } + } + document.addEventListener("click", checkIfClickedOutsideModal); + + return () => { + document.removeEventListener("click", checkIfClickedOutsideModal); + } + } + }, [navOpen]) + + const toggleNavOpen = () => + { + setNavOpen(!navOpen); + } + // Sets the index of the hovered notification + // Shows delete button on hover + const showDeleteButton = (index) => + { + setShowDeleteBtn(index); + } + const hideDeleteButton = () => + { + setShowDeleteBtn(-1); + } + + const removeNotification = (index, id = null) => { + let notif = null; + if (index >= 0) notif = notifications[index]; + if (id == null) id = notif.id; + + deleteNotification.mutate({ + notifId: id, + deleteAll: false, + successFunc: () => { + Object.keys(notifications).forEach((key, index) => { + if (notifications[key].id == id) + { + notifications[key].remove = true; + numNotifications.current--; + return; + } + }) + }, + errorFunc: (err) => { + setErrorMsg({notif_id: id, msg: 'Action failed.'}); + } + }); + } + + const removeAllNotifications = () => { + deleteNotification.mutate({ + notifId: '', + deleteAll: true, + successFunc: () => {}, + errorFunc: (err) => {} + }); + } + + const onChangeAccessLevel = (notif, access) => { + if (access != "") + { + document.getElementById(notif.id + '_action_button').className = "action_button notification_action enabled"; + } + } + + const onClickGrantAccess = (notif) => { + let accessLevel = document.getElementById(notif.id + '-access-level').value; + + if (accessLevel == "") + { + return; + } + + const expireTime = null; + + const userPerms = [{ + user_id: notif.from_id, + expiration: expireTime, + perms: { + [accessLevel]: true + }, + }] + setUserPerms.mutate({ + instId: notif.item_id, + permsObj: userPerms, + successFunc: (data) => { + // Redirect to widget + if (!window.location.pathname.includes('my-widgets')) + { + // No idea why this works + // But setting hash after setting pathname would set the hash first and then the pathname in URL + window.location.hash = notif.item_id + '-collab'; + window.location.pathname = '/my-widgets' + } + else + { + queryClient.invalidateQueries(['user-perms', notif.item_id]) + window.location.hash = notif.item_id + '-collab'; + } + + setErrorMsg({notif_id: notif.id, msg: ''}); + + removeNotification(-1, notif.id); + + // Close notifications + setNavOpen(false) + }, + errorFunc: (err) => { + setErrorMsg({notif_id: notif.id, msg: 'Action failed.'}) + } + }) + + + } + + let render = null; + let notificationElements = null; + let notificationIcon = null; + + if (notifications?.length > 0) { + notificationElements = [] + for (let index = notifications.length - 1; index >= 0; index--) + { + const notification = notifications[index]; + // If notification was deleted don't show + if (notification.remove) continue; + + let actionButton = null; + let grantAccessDropdown = null; + if (notification.action == "access_request") + { + grantAccessDropdown =
+

Grant Access

+ +
+ actionButton = + } + let createdAt = new Date(0); + createdAt.setUTCSeconds(notification.created_at) + let notifRow =
showDeleteButton(index)} + onMouseLeave={hideDeleteButton} + > + +
+
${notification.subject}

`}}>
+ { grantAccessDropdown } + { actionButton } +

Sent on {createdAt.toLocaleString()}

+
+ {removeNotification(index)}} + /> +

{errorMsg.notif_id == notification.id ? errorMsg.msg : ''}

+
+ + notificationIcon = + + + notificationElements.push(notifRow) + } + + // In the case that some notifications were removed, we don't want to render the empty notificationElements + if (notificationElements.length > 0) + { + render = ( +
+ { notificationIcon } + { navOpen ? +
+

Messages:

+ { notificationElements } + removeAllNotifications()}>Remove all Notifications +
+ : <> } +
+ ) + } + } + else + { + render = null; + + // Keeping this here in case the empty notification icon gets used + notificationElements =

You have no messages!

+ + notificationIcon = + + } + + return render; } export default Notifications diff --git a/src/components/pre-embed-common-styles.scss b/src/components/pre-embed-common-styles.scss index a67fbc50e..7dbc158eb 100644 --- a/src/components/pre-embed-common-styles.scss +++ b/src/components/pre-embed-common-styles.scss @@ -75,6 +75,17 @@ body { text-align: center; clear: both; + &.pre-embed { + padding: 2em 0; + + background: #f3f3f3; + border-radius: 4px; + + .action_button { + margin: 24px; + } + } + h2 { font-size: 18pt; } @@ -102,11 +113,22 @@ body { .widget_info { position: relative; + display: flex; + justify-content: flex-start; + align-items: center; + gap: 1em; min-height: 122px; - padding-left: 110px; + width: calc(100% + 20px); - list-style: none; + top: -15px; + left: -10px; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + + background: #0093e7; + color: #fff; + li { display: block; margin-right: 10px; @@ -126,18 +148,7 @@ body { } .widget_icon { - position: absolute; - top: 0; - left: -25px; - background: #0093e7; - width: 92px; - height: 92px; - padding: 15px; - text-align: center; - color: #fff; - font-size: 10px; - line-height: 13px; - box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.4); + padding-left: 15px; img { width: 92px; @@ -149,6 +160,7 @@ body { } ul.widget_about { + margin: 0; padding: 0; list-style-type: none; @@ -169,6 +181,33 @@ body { } } + .widget-embed-footer { + position: absolute; + bottom: 5px; + + display: flex; + justify-content: space-between; + align-items: center; + + width: calc(100% - 20px); + + font-size: 0.7em; + font-weight: 400; + + color: #5e5e5e; + + a { + &.materia-logo { + + img { + width: auto; + height: 15px; + margin-top: 1px; + } + } + } + } + div#form { ul { diff --git a/src/components/pre-embed-placeholder.jsx b/src/components/pre-embed-placeholder.jsx index ae8e411c9..397aaaec8 100644 --- a/src/components/pre-embed-placeholder.jsx +++ b/src/components/pre-embed-placeholder.jsx @@ -1,8 +1,10 @@ import React, { useState, useEffect} from 'react' import Summary from './widget-summary' +import EmbedFooter from './widget-embed-footer' import './pre-embed-common-styles.scss' + const PreEmbedPlaceholder = () => { const [instId, setInstId] = useState(null) @@ -27,9 +29,10 @@ const PreEmbedPlaceholder = () => {
-
- Play + +
) diff --git a/src/components/profile-page.jsx b/src/components/profile-page.jsx index aac336ccd..2ba78bf94 100644 --- a/src/components/profile-page.jsx +++ b/src/components/profile-page.jsx @@ -21,6 +21,7 @@ const ProfilePage = () => { queryKey: 'user', queryFn: apiGetUser, staleTime: Infinity, + retry: false, onError: (err) => { if (err.message == "Invalid Login") { setAlertDialog({ diff --git a/src/components/question-generator.jsx b/src/components/question-generator.jsx new file mode 100644 index 000000000..3ed1fb11f --- /dev/null +++ b/src/components/question-generator.jsx @@ -0,0 +1,149 @@ +import useQuestionGeneration from "./hooks/useQuestionGeneration" +import React, { useState, useEffect, useRef } from "react" +import './question-generator.scss' +import LoadingIcon from './loading-icon' + +const getInstId = () => { + const urlParams = new URLSearchParams(window.location.search); + const instId = urlParams.get('inst_id'); + return instId == 'undefined' ? null : instId; +} + +const getWidgetId = () => { + const urlParams = new URLSearchParams(window.location.search); + const widgetId = urlParams.get('widget_id'); + return widgetId ? widgetId : null; +} + +const QsetGenerator = () => { + const generateQuestion = useQuestionGeneration() + + const [instId, setInstId] = useState(getInstId()) + const [widgetId, setWidgetId] = useState(getWidgetId()) + const [topic, setTopic] = useState('') + const [includeImages, setIncludeImages] = useState(false) + const [numQuestions, setNumQuestions] = useState(8) + const [buildOffExisting, setBuildOffExisting] = useState(false) + + const [topicError, setTopicError] = useState('') + const [numberError, setNumberError] = useState('') + const [warning, setWarning] = useState('') + const [serverError, setServerError] = useState('') + + const loading = useRef(false) + + useEffect(() => { + if (numQuestions < 1) setNumberError('Please enter a number greater than 0') + else if (numQuestions > 16) setWarning('Note: Generating this many questions will take a while and may not work at all.') + else { + setNumberError('') + setWarning('') + } + },[numQuestions]) + + const onClickGenerate = () => { + + // validation functions required since this is an event handler + if (loading.current || ! validateNumQuestions() || ! validateTopic()) return false + + loading.current = true + + generateQuestion.mutate({ + inst_id: instId, + widget_id: widgetId, + topic: topic, + include_images: includeImages, + num_questions: numQuestions, + build_off_existing: buildOffExisting, + successFunc: (result) => { + window.parent.Materia.Creator.onQsetReselectionComplete( + JSON.stringify(result.qset), + true, // is generated + result.version, + result.title + ) + loading.current = false + }, + errorFunc: (err) => { + console.error(err) + setServerError('Error generating questions. Please try again.') + loading.current = false + } + }) + } + + const closeDialog = () => window.parent.Materia.Creator.onQsetReselectionComplete(null) + + const validateTopic = () => { + if (!topic.length) { + setTopicError('Don\'t forget to add a topic!') + return false + } else { + setTopicError('') + return true + } + } + + const validateNumQuestions = () => { + return numQuestions > 0 + } + + const onTopicChange = (e) => { + if (e.target.value.length > 0) { + setTopic(e.target.value) + setTopicError('') + } + else setTopicError('Don\'t forget to add a topic!') + } + + const onNumberChange = (e) => { + setNumQuestions(e.target.value) + } + + return ( +
+

Generate Questions

+ {loading.current &&
+ +

Generating questions. Do not close this window.

+
} +
+ Question Generation is powered by AI, so errors in the generated content can occur. After generation is complete you will be prompted to keep the content or discard it. You may need + to make edits to the generated content before saving your widget. + Note that this feature will only create text content. Image or media generation is not supported. + {serverError} +
+ {topicError} + + + The topic should be brief, concise, and describe the desired content of the widget. You may need to + experiment with specificity to achieve desired results. + +
+
+ + {numberError} + +
+ {/*
+ setIncludeImages(e.target.checked)}/> + +
*/} +
+ + setBuildOffExisting(e.target.checked)}/> + + If selected, generated content will be appended to existing content. If unselected, generated content will replace existing content. + +
+ {warning} + +
+
+ Cancel +
+
+ ) +} + +export default QsetGenerator; \ No newline at end of file diff --git a/src/components/question-generator.scss b/src/components/question-generator.scss new file mode 100644 index 000000000..cc7cde4ce --- /dev/null +++ b/src/components/question-generator.scss @@ -0,0 +1,171 @@ +@import './include.scss'; + +.generate { + border-radius: 5px; + margin: 10px; + + h1 { + background: #3690E6; + padding: 10px; + margin: 0px; + font-size: 1em; + font-weight: bold; + color: #ffffff; + } + + .loading { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + background-color: rgba(0, 0, 0, 0.5); + + backdrop-filter: blur(10px); + position: absolute; + top: 0; + left: 0; + width: 100%; + z-index: 10; + + p { + margin-bottom: 150px; + font-size: 1.1em; + color: white; + } + } + + #generate_form { + margin: 1.5em; + display: flex; + flex-direction: column; + gap: 0.35em; + + label { + font-weight: 400; + } + + input { + border: solid 1.5px #3690E6; + border-radius: 3px; + + &.invalid { + border: 1.5px solid red; + } + + &.warning { + border: 1.5px solid #d87a00; + } + } + + .description { + display: block; + padding: 1em; + background: $very-light-gray; + + font-size: 0.85em; + font-weight: 400; + + border-radius: 10px; + } + + #topic-field { + display: flex; + flex-direction: column; + gap: 0.5em; + + #topic { + padding: 0.5em; + } + } + + #num-questions-field { + position: relative; + display: flex; + flex-direction: row; + justify-content: space-between; + margin-top: 0.5em; + + input { + max-width: 100px; + } + + label { + background: #fff; + padding-right: 0.5em; + } + + &:after { + position: absolute; + top: 0.65em; + left: 0; + z-index: -1; + content: ''; + display: block; + width: calc(100% - 120px); + border-bottom: solid 1px #bbb; + } + } + + #build-off-existing-field { + position: relative; + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-between; + margin: 0.5em 0; + + &:after { + position: absolute; + top: 0.65em; + left: 0; + z-index: -1; + content: ''; + display: block; + width: calc(100% - 30px); + border-bottom: solid 1px #bbb; + } + + label { + background: #fff; + padding-right: 0.5em; + } + + #build-off-existing { + margin-bottom: 0.5em; + margin-right: 0.5em; + } + + .description { + flex-basis: 100%; + } + } + } + .actions { + position: fixed; + width: 80px; + left: 50%; + bottom: 0px; + margin-left: -40px; + padding-bottom: 14px; + z-index: 10; + + text-align: center; + font-size: 1.1em; + + a { + color: #000000; + } + } + + .error { + color: $color-red; + font-size: 0.8em; + font-weight: 400; + } + + .warning { + color: #d87a00; + font-size: 0.8em; + font-weight: 400; + } +} diff --git a/src/components/question-history.jsx b/src/components/question-history.jsx index 91fe737d9..3cc8b8137 100644 --- a/src/components/question-history.jsx +++ b/src/components/question-history.jsx @@ -20,6 +20,7 @@ const QuestionHistory = () => { queryFn: () => apiGetQuestionSetHistory(instId), enabled: !!instId, staleTime: Infinity, + retry: false, onError: (err) => { setError("Error fetching question set history.") console.error(err.cause) @@ -55,17 +56,18 @@ const QuestionHistory = () => { if (!!saves) { saves.forEach((save) => { if (id == save.id) { - return window.parent.Materia.Creator.onQsetHistorySelectionComplete( + return window.parent.Materia.Creator.onQsetReselectionComplete( JSON.stringify(save.data), + false, // is generated save.version, - save.created_at + null ) } }) } } - const closeDialog = () => window.parent.Materia.Creator.onQsetHistorySelectionComplete(null) + const closeDialog = () => window.parent.Materia.Creator.onQsetReselectionComplete(null) let savesRender = null let noSavesRender = null diff --git a/src/components/score-overview.jsx b/src/components/score-overview.jsx index 105b86297..dc7b69356 100644 --- a/src/components/score-overview.jsx +++ b/src/components/score-overview.jsx @@ -12,7 +12,8 @@ const ScoreOverview = ({inst_id, single_id, overview, attemptNum, isPreview, gue queryKey: ['score-summary', inst_id], queryFn: () => apiGetScoreSummary(inst_id), staleTime: Infinity, - enabled: !!inst_id && !single_id + enabled: !!inst_id && !single_id, + retry: false }) let scoreGraphRender = null diff --git a/src/components/scores.jsx b/src/components/scores.jsx index c06672a92..edc0803b4 100644 --- a/src/components/scores.jsx +++ b/src/components/scores.jsx @@ -76,6 +76,7 @@ const Scores = ({ inst_id, play_id, single_id, send_token, isEmbedded, isPreview enabled: false, // enabled is set to false so the query can be manually called with the refetch function staleTime: Infinity, refetchOnWindowFocus: false, + retry: false, onSuccess: (result) => { _populateScores(result.scores) setAttemptsLeft(result.attempts_left) @@ -99,6 +100,7 @@ const Scores = ({ inst_id, play_id, single_id, send_token, isEmbedded, isPreview enabled: false, // enabled is set to false so the query can be manually called with the refetch function staleTime: Infinity, refetchOnWindowFocus: false, + retry: false, onSuccess: (result) => { _populateScores(result) }, @@ -138,6 +140,7 @@ const Scores = ({ inst_id, play_id, single_id, send_token, isEmbedded, isPreview queryFn: () => apiGetScoreDistribution(inst_id), enabled: false, staleTime: Infinity, + retry: false, onSuccess: (data) => { _sendToWidget('scoreDistribution', [data]) }, diff --git a/src/components/settings-page.jsx b/src/components/settings-page.jsx index 729b544cd..ce0c1ebd7 100644 --- a/src/components/settings-page.jsx +++ b/src/components/settings-page.jsx @@ -21,6 +21,7 @@ const SettingsPage = () => { queryKey: 'user', queryFn: apiGetUser, staleTime: Infinity, + retry: false, onError: (err) => { if (err.message == "Invalid Login") { setAlertDialog({ diff --git a/src/components/support-page.jsx b/src/components/support-page.jsx index e26e6dc92..c8db863f8 100644 --- a/src/components/support-page.jsx +++ b/src/components/support-page.jsx @@ -15,6 +15,7 @@ const SupportPage = () => { queryKey: 'user', queryFn: apiGetUser, staleTime: Infinity, + retry: false, onError: (err) => { if (err.message == "Invalid Login") { window.location.href = '/login' @@ -29,6 +30,7 @@ const SupportPage = () => { queryFn: () => apiSearchInstances(widgetHash), enabled: widgetHash != undefined && widgetHash != selectedInstance?.id, staleTime: Infinity, + retry: false, onError: (err) => { if (err.message == "Invalid Login") { window.location.href = '/login' diff --git a/src/components/support-selected-instance.jsx b/src/components/support-selected-instance.jsx index 3ffb7b886..f7b9d2232 100644 --- a/src/components/support-selected-instance.jsx +++ b/src/components/support-selected-instance.jsx @@ -76,6 +76,7 @@ const SupportSelectedInstance = ({inst, currentUser, onCopySuccess, embed = fals enabled: !!inst && inst.id !== undefined, placeholderData: null, staleTime: Infinity, + retry: false, onError: (err) => { if (err.message == "Invalid Login") { setInvalidLogin(true) diff --git a/src/components/user-admin-page.jsx b/src/components/user-admin-page.jsx index 66198f748..de72cdec5 100644 --- a/src/components/user-admin-page.jsx +++ b/src/components/user-admin-page.jsx @@ -14,6 +14,7 @@ const UserAdminPage = () => { queryKey: 'user', queryFn: apiGetUser, staleTime: Infinity, + retry: false, onError: (err) => { if (err.message == "Invalid Login") { window.location.href = '/login' @@ -29,6 +30,7 @@ const UserAdminPage = () => { enabled: userHash != undefined && userHash != selectedUser?.id, placeholderData: null, staleTime: Infinity, + retry: false, onError: (err) => { if (err.message == "Invalid Login") { window.location.href = '/login' diff --git a/src/components/user-admin-selected.jsx b/src/components/user-admin-selected.jsx index f59ea5479..93e90d4b5 100644 --- a/src/components/user-admin-selected.jsx +++ b/src/components/user-admin-selected.jsx @@ -14,6 +14,7 @@ const UserAdminSelected = ({selectedUser, currentUser, onReturn}) => { queryFn: () => apiGetInstancesForUser(updatedUser.id), placeholderData: null, staleTime: Infinity, + retry: false, onError: (err) => { if (err.message == "Invalid Login") { window.location.href = '/login' diff --git a/src/components/widget-admin-page.jsx b/src/components/widget-admin-page.jsx index 0ef723997..5fc596629 100644 --- a/src/components/widget-admin-page.jsx +++ b/src/components/widget-admin-page.jsx @@ -14,6 +14,7 @@ const WidgetAdminPage = () => { queryKey: ['widgets'], queryFn: apiGetWidgetsAdmin, staleTime: Infinity, + retry: false, onSuccess: (widgetData) => { widgetData.forEach((w) => { w.icon = iconUrl('/widget/', w.dir, 60) diff --git a/src/components/widget-creator-page.scss b/src/components/widget-creator-page.scss index 5e07ff2b5..b6c53df53 100644 --- a/src/components/widget-creator-page.scss +++ b/src/components/widget-creator-page.scss @@ -52,7 +52,7 @@ a { &:before { content: 'Editing'; // background: #b944cc; - + position: absolute; bottom: 0; left: 0; @@ -63,7 +63,7 @@ a { margin: 10px 0; // border-right: solid 1px #aaa; - + font-size: 24px; font-weight: bold; // color: #ffffff; @@ -103,7 +103,7 @@ a { } } -#qset-rollback-confirmation-bar { +.confirmation-bar { position: relative; display: flex; justify-content: space-around; @@ -132,6 +132,7 @@ a { padding: 14px 0; font-size: 0.8em; + white-space: break-spaces; span { font-weight: bold; @@ -155,6 +156,14 @@ a { background: linear-gradient(#ffffff, #ffe5cc); } } + + &#qset-generation-confirmation-bar { + background-color: #3690E6; + + button:hover { + background: linear-gradient(#ffffff, #bbdeff); + } + } } .dot { @@ -184,7 +193,7 @@ a { margin: 15px 0; background: none; - + font-size: 21px; font-weight: 700; color: #000000; @@ -221,7 +230,7 @@ a { } .edit_button { - position: relative; + position: relative; display: inline-block; margin: 10px 0 0 6px; diff --git a/src/components/widget-creator.jsx b/src/components/widget-creator.jsx index 79194916d..a980f300b 100644 --- a/src/components/widget-creator.jsx +++ b/src/components/widget-creator.jsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { useQuery } from 'react-query' import LoadingIcon from './loading-icon'; -import { apiGetWidgetInstance, apiGetQuestionSet, apiCanBePublishedByCurrentUser, apiSaveWidget, apiGetWidgetLock, apiGetWidget, apiAuthorVerify} from '../util/api' +import { apiGetWidgetInstance, apiGetQuestionSet, apiCanBePublishedByCurrentUser, apiSaveWidget, apiGetWidgetLock, apiGetWidget, apiAuthorVerify, apiIsGenerable, apiWidgetPromptGenerate} from '../util/api' import NoPermission from './no-permission' import Alert from './alert' import { creator } from './materia-constants'; @@ -31,6 +31,7 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { creatorGuideUrl: window.location.pathname.substring(0, window.location.pathname.lastIndexOf('/')) + '/creators-guide', showActionBar: true, showRollbackConfirm: false, + showGenerationConfirm: false, saveStatus: 'idle', saveMode: null, previewUrl: null, @@ -39,7 +40,9 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { popupState: null, returnUrl: null, returnLocation: 'Widget Catalog', - directUploadMediaFile: null + directUploadMediaFile: null, + canGenerateQset: false, + isTimeoutRunning: false }) const [alertDialog, setAlertDialog] = useState({ @@ -71,9 +74,11 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { queryFn: () => apiGetWidget(widgetId), enabled: !!widgetId, staleTime: Infinity, + retry: false, onSuccess: (info) => { if (info) { setInstance({ ...instance, widget: info }) + setCreatorState({...creatorState, canGenerateQset: info.is_generable == "1"}) } }, onError: (error) => { @@ -88,6 +93,7 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { queryFn: () => apiGetWidgetInstance(instId), enabled: !!instId, staleTime: Infinity, + retry: false, onSuccess: (data) => { // this value will include a qset that's always empty // it will override the instance's qset property even if it's already set @@ -108,6 +114,7 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { staleTime: Infinity, placeholderData: null, enabled: !!instIdRef.current, // requires instance state object to be prepopulated + retry: false, onSuccess: (data) => { if (data) { setCreatorState({...creatorState, invalid: false}) @@ -126,6 +133,7 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { queryFn: () => apiCanBePublishedByCurrentUser(instance.widget?.id), enabled: instance?.widget !== null, staleTime: Infinity, + retry: false, onSuccess: (success) => { if (!success && !instance.is_draft) { onInitFail('Widget type can not be edited by students after publishing.') @@ -142,6 +150,7 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { staleTime: 30000, refetchInterval: 30000, enabled: creatorState.heartbeatEnabled, + retry: 1, onError: (error) => { onInitFail(error) }, @@ -160,6 +169,7 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { queryFn: () => apiGetWidgetLock(instance.id), enabled: !!instance.id, staleTime: Infinity, + retry: false, onSuccess: (success) => { if (!success) { onInitFail('Someone else is editing this widget, you will be able to edit after they finish.') @@ -243,7 +253,7 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { // this hook will then fire a second time when a new save postMessage is sent // the second hook will initialize an existing widget with the newly provided qset data // note: this condition will also apply when rolling back and applying the original cached qset - if (!!instIdRef.current && instance.qset && creatorState.reloadWithQset) { + if (((!!instIdRef.current && instance.qset) || instance.preSaveSpecialCondition) && creatorState.reloadWithQset) { // flip to false because creator will re-init and send start postMessage setWidgetReady(false) @@ -262,6 +272,11 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { creatorShouldInitRef.current = false + } else if (instance.preSaveSpecialCondition) { + // preSaveSpecialCondition is a flag set when generating a qset for an unsaved instance + let args = [instance.name ? instance.name : 'My Generated Widget', instance, instance.qset.data, instance.qset.version, window.BASE_URL, window.MEDIA_URL] + sendToCreator('initExistingWidget', args) + } else if (!instIdRef.current) { let args = [instance.widget, window.BASE_URL, window.MEDIA_URL] @@ -278,7 +293,9 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { creatorShouldInitRef.current = true setInstance({ ...instance, - qset: creatorState.reloadWithQset + qset: creatorState.reloadWithQset, + ...( creatorState.reloadWithQset.title && { name: creatorState.reloadWithQset.title }), // fancy syntax to only apply the name property when reloadWithQset.title is set + ...( ! instIdRef.current && { preSaveSpecialCondition: true }) // fancy syntax to ensure preSaveSpecialCondition is only applied when instIdRef.current is unavailable }) } },[creatorState.reloadWithQset]) @@ -289,6 +306,9 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { if (!!saveWidgetComplete) { if (saveWidgetComplete == 'save') { setCreatorState(creatorState => ({...creatorState, saveText: 'Draft Saved', saveStatus: 'idle'})) + if(!creatorState.isTimeoutRunning) { + setCreatorState({...creatorState, saveText: 'Draft Saved', saveStatus: 'idle', isTimeoutRunning: true}); + } } else if (saveWidgetComplete == 'preview') { setCreatorState(creatorState => ({...creatorState, saveStatus: 'idle'})) @@ -298,6 +318,21 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { } },[saveWidgetComplete]) + useEffect( () => { + if(creatorState.isTimeoutRunning) { + const timeoutID = setTimeout( () => { + setCreatorState( prevState => ({ + ...prevState, + saveText: 'Save Draft', + isTimeoutRunning: false + })); + }, 5000); + + return () => clearTimeout(timeoutID); + } + + }, [creatorState.isTimeoutRunning]); + /* =========== postMessage handlers =========== */ const onPostMessage = (e) => { @@ -335,6 +370,8 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { return showMediaImporter(msg.data) case 'directUploadMedia': // the creator is requesting to directly upload a media file, bypassing user input return directUploadMedia(msg.data) + case 'submitPrompt': + return submitPromptForCreator(msg.data) case 'setHeight': // the height of the creator has changed return setHeight(`${msg.data[0]}px`) case 'alert': @@ -378,6 +415,17 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { } const save = (instanceName, qset, version = 1) => { + //cancel saving of the widget if title is too long to prevent crashing + if(instanceName.length>100) { + setAlertDialog({ + enabled: true, + title: 'Title too long', //the max length for title in my testing is 100 + message: 'Title must be less than 100 characters', + fatal: false, + enableLoginButton: false + }); + return false; + } let newWidget = { widget_id: widgetId, name: instanceName, @@ -448,7 +496,13 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { enableLoginButton: true }) - setCreatorState({...creatorState, heartbeatEnabled: false}) + setCreatorState({ + ...creatorState, + heartbeatEnabled: false, + saveText: 'Failed to save', + saveStatus: 'idle', + isTimeoutRunning: true + }); } else { setAlertDialog({ enabled: true, @@ -457,7 +511,16 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { fatal: false, enableLoginButton: false }) + //also update the text on the Save Draft Button + setCreatorState({ + ...creatorState, + saveText: 'Failed to save', + saveStatus: 'idle', + isTimeoutRunning: true + }); + } + } else { setAlertDialog({ enabled: true, @@ -466,6 +529,13 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { fatal: false, enableLoginButton: false }) + setCreatorState({ + ...creatorState, + saveText: 'Failed to save', + saveStatus: 'idle', + isTimeoutRunning: true + }); + } } @@ -494,10 +564,11 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { showEmbedDialog(`${window.BASE_URL}qsets/import/?inst_id=${instance.id}`, 'embed_dialog') } - // const showQsetHistoryConfirmation = () => { - // } + const showQuestionGenerator = () => { + showEmbedDialog(`${window.BASE_URL}qsets/generate/?inst_id=${instance.id}&widget_id=${widgetId}`, 'embed_dialog') + } - const qsetRollbackConfirm = (confirm) => { + const qsetConfirm = (confirm) => { // if asked to confirm rollback, we apply the cached qset to reloadWithQset // doing so will trigger the hook when reloadWithQset updates @@ -505,20 +576,24 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { // otherwise, nothing is required except to restore the action bar if (!confirm) { + // rollback to the cached qset let qsetToApply = creatorState.cachedQset setCreatorState({ ...creatorState, reloadWithQset: qsetToApply, cachedQset: null, showActionBar: true, - showRollbackConfirm: false + showRollbackConfirm: false, + showGenerationConfirm: false, }) } else { + // just remove the confirmation bar and show the action bar setCreatorState({ ...creatorState, cachedQset: null, showActionBar: true, - showRollbackConfirm: false + showRollbackConfirm: false, + showGenerationConfirm: false, }) } } @@ -536,6 +611,13 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { }) } + const submitPromptForCreator = (prompt) => { + apiWidgetPromptGenerate(prompt).then((result) => { + if (result.response && result.success) sendToCreator('promptResponse', [result.response]) + else sendToCreator('promptRejection') + }) + } + const setHeight = (height) => { // *crickets* } @@ -582,20 +664,20 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { } }, - // When a qset is selected from the prior saves list - onQsetHistorySelectionComplete(qset, version = 1) { + // When a new qset is selected from the prior saves list or generated + onQsetReselectionComplete(qset, showGenerationConfirm = false, version = 1, title = null) { if (!qset) { setCreatorState({ ...creatorState, dialogPath: '', dialogType: 'embed_dialog', showActionBar: true, - showRollbackConfirm: false + showRollbackConfirm: false, + showGenerationConfirm: false }) } else { requestSave('history') - let parsedQsetData = JSON.parse(qset) setCreatorState({ @@ -605,11 +687,12 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { reloadWithQset: { data: parsedQsetData, version: version, - id: parsedQsetData.id + id: parsedQsetData.id, + ...(title && { title: title }), }, - // cachedQset: instance.qset, showActionBar: false, - showRollbackConfirm: true + showRollbackConfirm: showGenerationConfirm ? false : true, + showGenerationConfirm: showGenerationConfirm, }) } }, @@ -714,7 +797,8 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { ←Return to {creatorState.returnLocation} { creatorState.hasCreatorGuide ? Creator's Guide : '' } { instance.id ? Save History : '' } - Import Questions... + Import + { creatorState.canGenerateQset ? Generate : <> } { editButtonsRender }
- + + + + ) + } + + let generationConfirmBarRender = null + if (creatorState.showGenerationConfirm) { + generationConfirmBarRender = ( +
+

Previewing Generated Questions

+

Select Cancel to undo any changes made by the question generator. Select Keep to commit to using this generated version.

+ +
) } @@ -803,6 +899,7 @@ const WidgetCreator = ({instId, widgetId, minHeight='', minWidth=''}) => { { popupRender } { actionBarRender } { rollbackConfirmBarRender } + { generationConfirmBarRender }