diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d7373c1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +res/backup-moodle2-course-qa-ref.mbz filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..dcc6765 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,55 @@ +name: Build and publish docs + +on: + push: + branches: + - master + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV + - uses: actions/cache@v4 + with: + key: mkdocs-material-${{ env.cache_id }} + path: .cache + restore-keys: | + mkdocs-material- + - run: pip install \ + mkdocs \ + mkdocs-material \ + mkdocs-material-extensions \ + mkdocs-material[imaging] \ + mkdocs-minify-plugin \ + mkdocs-glightbox \ + mkdocs-get-deps + - run: mkdocs build + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: ./site + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/moodle-plugin-ci.yml b/.github/workflows/moodle-plugin-ci.yml index 920a65b..cc1ad3d 100644 --- a/.github/workflows/moodle-plugin-ci.yml +++ b/.github/workflows/moodle-plugin-ci.yml @@ -1,6 +1,10 @@ name: Moodle Plugin CI -on: [push, pull_request] +on: + push: + branches: [ "master", "develop" ] + pull_request: + branches: [ "master", "develop" ] jobs: test: @@ -53,6 +57,21 @@ jobs: - {moodle-branch: 'MOODLE_403_STABLE', php: '8.2', database: 'pgsql'} - {moodle-branch: 'MOODLE_403_STABLE', php: '8.2', database: 'mariadb'} + # Moodle 4.4, PHP 8.1 to 8.3, PostgreSQL and MariaDB + - {moodle-branch: 'MOODLE_404_STABLE', php: '8.1', database: 'pgsql'} + - {moodle-branch: 'MOODLE_404_STABLE', php: '8.1', database: 'mariadb'} + - {moodle-branch: 'MOODLE_404_STABLE', php: '8.2', database: 'pgsql'} + - {moodle-branch: 'MOODLE_404_STABLE', php: '8.2', database: 'mariadb'} + - {moodle-branch: 'MOODLE_404_STABLE', php: '8.3', database: 'pgsql'} + - {moodle-branch: 'MOODLE_404_STABLE', php: '8.3', database: 'mariadb'} + + # Moodle 4.5, PHP 8.1 to 8.3, PostgreSQL and MariaDB + - {moodle-branch: 'MOODLE_405_STABLE', php: '8.1', database: 'pgsql'} + - {moodle-branch: 'MOODLE_405_STABLE', php: '8.1', database: 'mariadb'} + - {moodle-branch: 'MOODLE_405_STABLE', php: '8.2', database: 'pgsql'} + - {moodle-branch: 'MOODLE_405_STABLE', php: '8.2', database: 'mariadb'} + - {moodle-branch: 'MOODLE_405_STABLE', php: '8.3', database: 'pgsql'} + - {moodle-branch: 'MOODLE_405_STABLE', php: '8.3', database: 'mariadb'} steps: - name: Check out repository code uses: actions/checkout@v3 @@ -75,7 +94,10 @@ jobs: echo $(cd ci/bin; pwd) >> $GITHUB_PATH echo $(cd ci/vendor/bin; pwd) >> $GITHUB_PATH sudo locale-gen en_AU.UTF-8 - echo "NVM_DIR=$HOME/.nvm" >> $GITHUB_ENV + + # Install nvm v0.39.7 as a temporary workaround for issue: + # https://github.com/moodlehq/moodle-plugin-ci/issues/309 + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash - name: Install moodle-plugin-ci run: moodle-plugin-ci install --plugin ./plugin --db-host=127.0.0.1 @@ -99,10 +121,9 @@ jobs: if: ${{ !cancelled() }} run: moodle-plugin-ci phpmd - # FIXME: Re-enable this step - #- name: Moodle Code Checker - # if: ${{ !cancelled() }} - # run: moodle-plugin-ci phpcs --max-warnings 0 + - name: Moodle Code Checker + if: ${{ !cancelled() }} + run: moodle-plugin-ci phpcs --max-warnings 0 - name: Moodle PHPDoc Checker if: ${{ !cancelled() }} diff --git a/.gitignore b/.gitignore index caa32e6..311f248 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ +.cache .idea/ *.iml \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b76ff0..ae58935 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,150 @@ # Changelog +## Version X.X.X (YYYYMMDDXX) + +- Add hint about font rendering problems to the documentation + + +## Version 2.2.0 (2024102900) + +- Add student ID number to quiz attempt header +- Add student ID number to exported `attempts_metadata.csv` file inside quiz archives +- Allow student ID number to be used in attempt filename pattern as `${idnumber}` +- Fix creation of quiz archives with duplicate archive names (e.g., when using `quiz-archive-${quizid}-${quizname}` as the archive name pattern) +- Improve display of user firstname, lastname, and avatar in quiz attempt header +- Improve display of empty values in quiz attempt header (e.g., feedback, idnumber, ...) +- Fix name of `QUIZ_ARCHIVER_PREVENT_REDIRECT_TO_LOGIN` environment variable in archive worker documentation +- Fix single unit test suit execution command in developer documentation +- Improve content spacing in docs +- Only run Moodle CI for commits and PRs on master and develop branches to prevent duplicate runs + + +## Version 2.1.0 (2024101000) + +- Ensure compatibility with Moodle 4.5 (LTS) +- Create an official Quiz Archiver documentation website: [https://quizarchiver.gandrass.de/](https://quizarchiver.gandrass.de/) + - Great thanks to @melanietreitinger for reviewing and providing valuable feedback! +- Automate building and deployment of documentation website +- Cleanup and restructure existing documentation within README +- Add demo quiz archive worker information to admin settings page +- Fix job details dialog not showing up if artifact file was deleted but metadata still remains +- Fix PHP warning on autoinstall admin page +- Add Moodle 4.5 to automated (CI) test matrix + + +## Version 2.0.0 (2024082100) + +- Switch to semantic versioning (see README.md, Section: "Versioning and Compatibility") +- Fix rendering of GeoGebra applets under certain conditions +- Improve robustness of attempt page rendering state detection ("ready for export" detection) +- Improve status and error notifications for all actions (job creation, deletion, ...) +- Prevent form data resubmission on page reload +- Add tooltip to archive overview refresh button and list time of last page refresh +- Improve visual presentation of the quiz archive overview table +- Improve visual presentation of the quiz archive creation form +- Add complex examples (large image compression, GeoGebra applets) to reference course + +**Note:** Use of [moodle-quiz-archive-worker](https://github.com/ngandrass/moodle-quiz-archive-worker) `>= v2.0.0` is required. + + +## Version 1.4.0 (2024072900) + +- Show periodically updated progress of running archive jobs in job overview table and job details modal +- Creation of new job status values: + - `WAITING_FOR_BACKUP`: All attempt reports are generated and the archive worker service is waiting for the Moodle backup to be ready. + - `FINALIZING`: The archive worker service is finalizing the archive creation process (checksums, compression, ...). +- Create hover tooltip with help text for all job status values +- Add additional soft error handling to some web service functions +- Minor compatibility fixes for PHP 7.4 and Moodle 4.1 (LTS) +- Expanding unit test coverage to include the whole plugin logic +- Optimizing unit test code to improve readability and maintainability +- Create generic testing data generator +- Code quality improvements + +**Note:** Use of [moodle-quiz-archive-worker](https://github.com/ngandrass/moodle-quiz-archive-worker) `>= v1.6.0` is required. + + +## Version 1.3.0 (2024071800) + +- Optionally scale down large images within quiz reports to preserve space and keep PDF files compact +- Optionally compress images within quiz reports to preserve space and keep PDF files compact +- Fix image inlining for files with non-lowercase file extensions (e.g., `image.JPG`) +- Fix conditional hide/show of retention time in quiz archive form when locked +- Optimize order of settings in quiz archive form and plugin admin settings + +**Note:** Use of [moodle-quiz-archive-worker](https://github.com/ngandrass/moodle-quiz-archive-worker) `>= v1.5.0` is required. + + +## Version 1.2.10 (2024070900) + +- Full code overhaul to comply with the [Moodle Coding Style](https://moodledev.io/general/development/policies/codingstyle) +- Enforce strict coding style checks during CI runs / prior to any new releases +- Improve English and German translations + + +## Version 1.2.9 (2024070800) + +- Synchronize default job timeout setting with quiz archive worker and add hint about the additional timeout inside the + archive worker config +- Describe different job timeout settings inside the "Known Pitfalls" section of + the README file. +- Fix display of variables in archive / report names help texts in Moodle <= 4.2 + +_Note: Keep in mind to update your +[Quiz Archive Worker](https://github.com/ngandrass/moodle-quiz-archive-worker) too!_ + + +## Version 1.2.8 (2024052900) + +- Fix autoinstall admin UI form for Moodle 4.1 (LTS) +- Fix edge case during GDPR exports via the Moodle privacy API when using PHP 7.4 +- Fix webservice token generation on Moodle 4.1 (LTS) +- Largely extend the test coverage. Now almost everything is tested automatically + for all combinations of: + - Moodle version: 4.1 - 4.4 + - PHP versions: 7.4 - 8.3 + - Database backends: mariadb, pgsql +- Cleanup attempt report generation code +- Provide documentation how to run tests locally +- Fix typos + +_Note: Keep in mind to update your +[Quiz Archive Worker](https://github.com/ngandrass/moodle-quiz-archive-worker) too!_ + + +## Version 1.2.7 (2024051300) + +- Fix inlining of images with filenames that contains URL encoded characters (e.g., `image (1).jpg`) +- Fix inlining of Moodle theme icons (e.g., drag and drop markers) +- Fix PHP warning on quiz_archiver_generate_attempt_report webservice call +- Fix quiz header / summary table injection in Moodle 4.4+ +- Replace deprecated Moodle 4.4+ language strings + + +## Version 1.2.6 (2024042900) + +- Extend automated tests to cover Moodle 4.4 with PHP 8.1 to 8.3 using PostgreSQL and MariaDB +- Removal of deprecated function use for Moodle 4.4 (See MDL-67667) + + +## Version 1.2.5 (2024040900) + +- Add an automatic plugin configuration feature, to simplify the setup process (#15 - Thanks to @melanietreitinger) +- Display a welcome message with setup instructions during plugin installation +- Add support for automated configuration using a CLI script +- Add error message during job creation, when plugin is not fully configured yet +- Create quizzes with 100, 250, 500, and 1000 attempts in the reference course `res/backup-moodle2-course-qa-ref.mbz` +- Update installation instructions in README.md to reflect the new setup process + ## Version 1.2.4 (2024021901) - Fix image inlining for Moodle instances that reside in subdirectories (e.g., `https://your.domain/moodle`) - - Thanks a lot to @500gLychee for extensive testing and reporting! + - Thanks a lot to @500gLychee for extensive testing and reporting! - Fix inlining of miscellaneous local images that do not fall into any specific link type category - Detect quizzes without attempts and prevent archive creation until at least one attempt was registered - Create GitHub issue template forms for bug reports and feature requests - - Found a bug? Please report it here: https://github.com/ngandrass/moodle-quiz_archiver/issues + - Found a bug? Please report it here: https://github.com/ngandrass/moodle-quiz_archiver/issues ## Version 1.2.3 (2024011200) diff --git a/README.md b/README.md index df78ddb..64de6dd 100644 --- a/README.md +++ b/README.md @@ -34,11 +34,14 @@ service to remove load from Moodle and to eliminate the need to install a large number of software dependencies on the webserver. It can easily be [deployed using Docker](https://github.com/ngandrass/moodle-quiz-archive-worker#installation). -Available via the [Moodle Plugin Directory](https://moodle.org/plugins/quiz_archiver):\ -[![Moodle Plugin Directory](doc/moodle-plugin-directory-button.png)](https://moodle.org/plugins/quiz_archiver) +The Quiz Archiver is available via the [Moodle Plugin Directory](https://moodle.org/plugins/quiz_archiver):\ +[![Moodle Plugin Directory](docs/assets/moodle-plugin-directory-button.png)](https://moodle.org/plugins/quiz_archiver) +More information and detailed installation / setup instructions can be found in +the [official documentation](https://quizarchiver.gandrass.de/): + +[![Quiz Archiver: Official Documentation](docs/assets/docs-button.png)](https://quizarchiver.gandrass.de/) ------ ## Features @@ -68,7 +71,6 @@ Available via the [Moodle Plugin Directory](https://moodle.org/plugins/quiz_arch - Technical separation of Moodle and archive worker service - Data-minimising and security driven design ------ ## Concept @@ -85,401 +87,78 @@ webservice user to be created (see [Configuration](#configuration)). A single jo webservice token can only be used for the specific quiz that is associated with the job to restrict queryable data to the required minimum. ------ - -## Installation - -You can install this plugin like any other Moodle plugin, as described below. -However, keep in mind that you additionally need to deploy the external quiz -archive worker service for this plugin to work. - - -### Installing via uploaded ZIP file - -1. Log in to your Moodle site as an admin and go to _Site administration > - Plugins > Install plugins_. -2. Upload the ZIP file with the plugin code. You should only be prompted to add - extra details if your plugin type is not automatically detected. -3. Check the plugin validation report and finish the installation. - - -### Installing manually - -The plugin can be also installed by putting the contents of this directory to - - {your/moodle/dirroot}/mod/quiz/report/archiver - -Afterward, log in to your Moodle site as an admin and go to _Site administration > -Notifications_ to complete the installation. - -Alternatively, you can run - - $ php admin/cli/upgrade.php - -to complete the installation from the command line. - - -## Configuration - -The following sections describe the required steps to set up the plugin. - -In summary: You need to create a dedicated Moodle user, a global role to manage -permissions, setup a webservice for the archive worker, and set configuration -options for the Moodle plugin. - - -### 1. Prerequisites - -Installation of the additional [quiz archive worker service](https://github.com/ngandrass/moodle-quiz-archive-worker) -is mandatory for this plugin to work. - -**Detailed installation instructions can be found here: -[Quiz Archive Worker: Installation](https://github.com/ngandrass/moodle-quiz-archive-worker#installation)** - - -### 2. Create Moodle User and Role - -1. Create a designated Moodle user for the quiz archiver webservice - 1. Navigate to _Site Administration_ > _Users_ (1) > _Accounts_ > _Add a new user_ (2) - 2. Set a username (e.g. `quiz_archiver`) (3), a password (4), first and - lastname (5), and a hidden email address (6) - 3. Create the user (7) - - [![Screenshot: Configuration - Create Moodle User 1](doc/configuration/configuration_create_moodle_user_1_thumb.png)](doc/configuration/configuration_create_moodle_user_1.png) - [![Screenshot: Configuration - Create Moodle User 2](doc/configuration/configuration_create_moodle_user_2_thumb.png)](doc/configuration/configuration_create_moodle_user_2.png) - -2. Create a global role to handle permissions for the `quiz_archiver` Moodle user - 1. Navigate to _Site Administration_ > _Users_ (1) > _Permissions_ > _Define roles_ (2) - 2. Select _Add a new role_ (3) - 3. Set _Use role or archetype_ (4) to `No role` - 4. Upload the role definitions file from [res/moodle_role_quiz_archiver.xml](res/moodle_role_quiz_archiver.xml) (5). - This will automatically assign all required capabilities. You can check all - capabilities prior to role creation in the next step or by manually - inspecting the [role definition XML file](res/moodle_role_quiz_archiver.xml). - 5. Click on _Continue_ (6) to import the role definitions for review - 6. Optionally change the role name or description and create the role (7) - - [![Screenshot: Configuration - Create Role 1](doc/configuration/configuration_create_role_1_thumb.png)](doc/configuration/configuration_create_role_1.png) - [![Screenshot: Configuration - Create Role 2](doc/configuration/configuration_create_role_2_thumb.png)](doc/configuration/configuration_create_role_2.png) - [![Screenshot: Configuration - Create Role 3](doc/configuration/configuration_create_role_3_thumb.png)](doc/configuration/configuration_create_role_3.png) - [![Screenshot: Configuration - Create Role 4](doc/configuration/configuration_create_role_4_thumb.png)](doc/configuration/configuration_create_role_4.png) - -3. Assign the `quiz_archiver` Moodle user to the created role - 1. Navigate to _Site Administration_ > _Users_ (1) > _Permissions_ > _Assign system roles_ (2) - 2. Select the `Quiz Archiver Service Account` role (3) - 3. Search the created `quiz_archiver` Moodle user (4), select it in the list - of potential users (5), and add it to the role (6) - - [![Screenshot: Configuration - Assign Role 1](doc/configuration/configuration_assign_role_1_thumb.png)](doc/configuration/configuration_assign_role_1.png) - [![Screenshot: Configuration - Assign Role 2](doc/configuration/configuration_assign_role_2_thumb.png)](doc/configuration/configuration_assign_role_2.png) - [![Screenshot: Configuration - Assign Role 3](doc/configuration/configuration_assign_role_3_thumb.png)](doc/configuration/configuration_assign_role_3.png) - - -### 3. Setup Webservice - -1. Enable webservices globally - 1. Navigate to _Site Administration_ > _Server_ (1) > _Web services_ > _Overview_ (2) - 2. Click on _Enable web services_ (3), check the checkbox (4), and save the - changes (5) - 3. Navigate back to the _Overview_ (2) page - 4. Click on _Enable protocols_ (6), enable the _REST protocol_ (7), and save the - changes (8) - - [![Screenshot: Configuration - Enable Webservices 1](doc/configuration/configuration_enable_webservices_1_thumb.png)](doc/configuration/configuration_enable_webservices_1.png) - [![Screenshot: Configuration - Enable Webservices 2](doc/configuration/configuration_enable_webservices_2_thumb.png)](doc/configuration/configuration_enable_webservices_2.png) - [![Screenshot: Configuration - Enable Webservices 3](doc/configuration/configuration_enable_webservices_3_thumb.png)](doc/configuration/configuration_enable_webservices_3.png) - [![Screenshot: Configuration - Enable Webservices 4](doc/configuration/configuration_enable_webservices_4_thumb.png)](doc/configuration/configuration_enable_webservices_4.png) - -2. Create an external webservice for the quiz archive worker to use - 1. Navigate to _Site Administration_ > _Server_ (1) > _Web services_ > _External services_ (2) - 2. Under the _Custom services_ section, select _Add_ (3) - 3. Enter a name (e.g. `quiz_archiver`) (4) and enable it (5) - 4. Expand the additional settings (6), enable file up- and download (7) - 5. Create the new webservice by clicking _Add service_ (8) - - [![Screenshot: Configuration - Create Webservice 1](doc/configuration/configuration_create_webservice_1_thumb.png)](doc/configuration/configuration_create_webservice_1.png) - [![Screenshot: Configuration - Create Webservice 2](doc/configuration/configuration_create_webservice_2_thumb.png)](doc/configuration/configuration_create_webservice_2.png) - [![Screenshot: Configuration - Create Webservice 3](doc/configuration/configuration_create_webservice_3_thumb.png)](doc/configuration/configuration_create_webservice_3.png) - -3. Add all `quiz_archiver_*` webservice functions to the `quiz_archiver` external - service - 1. Navigate to _Site Administration_ > _Server_ (1) > _Web services_ > _External services_ (2) - 2. Open the _Functions_ page for the `quiz_archiver` webservice (3) - 3. Click the _Add functions_ link (4) - 4. Search for `quiz_archiver` (5) and add all `quiz_archiver_*` functions - 5. Save the changes by clicking _Add functions_ (6) - - [![Screenshot: Configuration - Assign Webservice Functions 1](doc/configuration/configuration_assign_webservice_functions_1_thumb.png)](doc/configuration/configuration_assign_webservice_functions_1.png) - [![Screenshot: Configuration - Assign Webservice Functions 2](doc/configuration/configuration_assign_webservice_functions_2_thumb.png)](doc/configuration/configuration_assign_webservice_functions_2.png) - [![Screenshot: Configuration - Assign Webservice Functions 3](doc/configuration/configuration_assign_webservice_functions_3_thumb.png)](doc/configuration/configuration_assign_webservice_functions_3.png) - [![Screenshot: Configuration - Assign Webservice Functions 4](doc/configuration/configuration_assign_webservice_functions_4_thumb.png)](doc/configuration/configuration_assign_webservice_functions_4.png) - - -### 4. Configure Plugin Settings - -1. Navigate to _Site Administration_ > _Plugins_ (1) > _Activity modules_ > - _Quiz_ > _Quiz Archiver_ (2) -2. Set `worker_url` (3) to the URL under which the quiz archive worker can be - reached (e.g., `http://quiz-archive-worker:5000` or `http://127.0.0.1:5000`) -3. Select the previously created `quiz_archiver` webservice for `webservice_id` (4) - from the drop-down menu -4. Enter the user ID of the previously created Moodle user for `webservice_userid` (5). - It can easily be found by navigating to the users profile page and inspecting - the page URL. It contains the user ID as the `id` query parameter. -5. (Optional) Specify a custom job timeout in minutes -6. (Optional) Specify a custom Moodle base URL. This is only required if you run - the quiz archive worker in an internal/private network, e.g., when using - Docker. If this setting is present, the public Moodle `$CFG->wwwroot` will be - replaced by the `internal_wwwroot` setting. - Example: `https://your.public.moodle/` will be replaced by `http://moodle.local/`. -7. Save all settings and create your first quiz archive (see [Usage](#usage)). -8. (Optional) Adjust the default [capability](#capabilities) assignments. - -[![Screenshot: Configuration - Plugin Settings 1](doc/configuration/configuration_plugin_settings_1_thumb.png)](doc/configuration/configuration_plugin_settings_1.png) -[![Screenshot: Configuration - Plugin Settings 2](doc/configuration/configuration_plugin_settings_2_thumb.png)](doc/configuration/configuration_plugin_settings_2.png) - - -### Known Pitfalls - -- **Access to (some) webservice functions fails** - - Ensure that webservices and the REST protocol are enabled globally. - - Ensure that all required webservice functions are enabled for the - `quiz_archiver` webservice. - - Ensure that the `quiz_archiver` webservice has the rights to download and - upload files. - - Ensure that the `quiz_archiver` webservice user has accepted all site policies - (e.g., privacy policy). -- **Upload of the final archive fails** - - Ensure you have configured `php` to accept large file uploads. The - `upload_max_filesize` and `post_max_size` settings in your `php.ini` should - be set to a value that is large enough to allow the upload of the largest - quiz archive file that you expect to be created. - - Ensure that your Moodle is configured to allow large file uploads. - `$CFG->maxbytes` should be set to the same value as PHP `upload_max_filesize`. - - If you are using an ingress webserver and `php-fpm` via FastCGI, ensure that - the `fastcgi_send_timeout` and `fastcgi_read_timeout` settings are long - enough to allow the upload of the largest quiz archive file that you expect. - Nginx usually signals this problem by returning a '504 Gateway Time-out' - after 60 seconds (default). - - Ensure that your antivirus plugin is capable of handling large files. When - using ClamAV you can control maximum file sizes by setting `MaxFileSize`, - `MaxScanSize`, and `StreamMaxLength` (when using a TCP socket) inside - `clamd.conf`. - - -## Capabilities - -The following capabilities are required for the listed actions: - -- `mod/quiz_archiver:view` (context: Module): Required to view the quiz archiver - overview page. It allows to download all created archives but does not allow do - create new or delete existing archives (read-only access). By default, assigned - to: `teacher`, `editingteacher`, `manager`. -- `mod/quiz_archiver:create` (context: Module): Allows creation of new quiz - archives (read-write access). By default, assigned to: `editingteacher`, - `manager`. -- `mod/quiz_archiver:delete`, (context: Module): Allows deletion of existing - quiz archives (read-write access). By default, assigned to: `editingteacher`, - `manager`. -- `mod/quiz_archiver:use_webservice` (context: System): Required to use any of - the webservice functions this plugin provides. The webservice user (created in - [Configuration](#configuration)) needs to have this capability in order to - create new quiz archives. - ------ - -## Usage - -Once installed and set up, quizzes can be archived by performing the following -steps: - -1. Navigate to a Moodle quiz -2. Inside the `Quiz administration` menu expand the `Results` section and click - on `Quiz Archiver` -3. Select the desired options and start the archive job by clicking the `Archive - quiz` button -4. Wait until the archive job is completed. You can now download the archive - from the `Quiz Archiver` page using the `Download archive` button. - -Created archives can be deleted by clicking the `Delete archive` button. Details, -including all selected settings during archive creation, e.g. number of attempts -or included components, can be viewed by clicking the `Details` button. - -If you encounter permission errors, ensure that the user has the required -[Capabilities](#capabilities) assigned. - - -## Advanced Usage - -This section discusses advanced usage of the plugin. - - -### Archive job presets (global defaults / policies) - -Default values for all archive job options can be configured globally via the -plugin settings page. By default, users are allowed to customize these settings -during archive creation. However, each setting can be locked individually to -prevent users from modifying it during archive creation. This allows the -enforcement of organization wide policies for archived quizzes. - -To customize these options: - -1. Navigate to _Site Administration_ > _Plugins_ (1) > _Activity modules_ > - _Quiz_ > _Quiz Archiver_ (2) -2. Scroll down to the _Archive presets_ section (3) -3. Set the desired default values for each option (4) - - Options can depend on another, as indicated by (6). This causes the - dependent option to be disabled, if the parent option is not set (e.g., - question feedback is not exported if question exporting is fully disabled) - - More options than shown in the screenshots are available. Scroll down to - see all (7) -4. (Optional) Lock individual options by checking the _Lock_ checkbox (5) - -Locked options will be grayed out during archive creation (8). - -[![Screenshot: Configuration - Archive job presets 1](doc/configuration/configuration_plugin_settings_1_thumb.png)](doc/configuration/configuration_plugin_settings_1.png) -[![Screenshot: Configuration - Archive job presets 2](doc/configuration/configuration_archive_job_presets_2_thumb.png)](doc/configuration/configuration_archive_job_presets_2.png) -[![Screenshot: Configuration - Archive job presets 3](doc/configuration/configuration_archive_job_presets_3_thumb.png)](doc/configuration/configuration_archive_job_presets_3.png) - - -### Automatic deletion of quiz archives (retention policy) - -Quiz archives can be automatically deleted after a specified retention period. -Automatic deletion can either be controlled on a per-archive basis or globally -via the [archive job presets](#archive-job-presets-global-defaults--policies). -Archives with expired lifetimes are deleted by an asynchronous task that is, by -default, scheduled to run every hour. Only the archived user data (attempt PDFs, -attachments, ...) is deleted, while the job metadata is kept until manually -deleted. This procedure allows to document the deletion of archive data in a -traceable manner, while the privacy relevant user data is deleted. - -![Screenshot: Job details modal - Automatic deletion](doc/screenshots/quiz_archiver_job_details_modal_autodelete.png) - -If an archive is scheduled for automatic deletion, its remaining lifetime is -shown in the job details modal, as depict above. You can access it via the -_Show details_ button on the quiz archiver overview page. Once deleted, archives -change their status from _'Finished'_ to _'Deleted'_. If you try to delete an -archive that is scheduled for automatic deletion before its retention period -expired, an extra warning message will be shown. - -#### Enable automatic deletion for a single quiz archive - -To enable the scheduled deletion for a single quiz archive: - -1. Navigate to the quiz archiver overview page -2. Expand the _Advanced settings_ section of the _Create new quiz archive_ form -3. Check the _Automatic deletion_ checkbox (1) -4. Set the desired retention period (2) -5. Create the archive job (3) - -[![Screenshot: Configuration - Automatic archive deletion](doc/configuration/configuration_job_autodelete_thumb.png)](doc/configuration/configuration_job_autodelete.png) - - -#### Enable automatic deletion globally - -Like any other archive settings, automatic deletion can be configured globally -using the [archive job presets](#archive-job-presets-global-defaults--policies). - - - -### Quiz archive signing using the Time-Stamp Protocol (TSP) - -Quiz archives and their creation date can be digitally signed by a trusted -authority using the [Time-Stamp Protocol (TSP)](https://en.wikipedia.org/wiki/Time_stamp_protocol) -according to [RFC 3161](https://www.ietf.org/rfc/rfc3161.txt). This can be used -to cryptographically prove the integrity and creation date of the archive at a -later point in time. Quiz archives can be signed automatically at creation or -manually later on. - -#### Enable archive signing globally - -1. Navigate to _Site Administration_ > _Plugins_ (1) > _Activity modules_ > - _Quiz_ > _Quiz Archiver_ (2) -2. Set `tsp_server_url` (3) to the URL of your desired TSP service -3. Globally enable archive signing by checking `tsp_enable` (4) -4. (Optional) Enable automatic archive signing by checking `tsp_automatic_signing` (5) -5. Save all settings (6) - -[![Screenshot: Configuration - TSP Settings 1](doc/configuration/configuration_plugin_settings_1_thumb.png)](doc/configuration/configuration_plugin_settings_1.png) -[![Screenshot: Configuration - TSP Settings 2](doc/configuration/configuration_tsp_settings_2_thumb.png)](doc/configuration/configuration_tsp_settings_2.png) +## Installation and Configuration -#### Accessing TSP data +You can find detailed installation and configuration instructions within the +[official documentation](https://quizarchiver.gandrass.de/). -Both the TSP query and the TSP response can be accessed via the job details -dialog. To do so, navigate to the quiz archiver overview page and click the -_Show details_ button for the desired archive job. +[![Quiz Archiver: Official Documentation](docs/assets/docs-button.png)](https://quizarchiver.gandrass.de/) -![Image of archive job details: TSP data](doc/screenshots/quiz_archiver_job_details_modal_tsp_data.png) +It guides you through the whole setup process from installing the Moodle plugin +to creating your first quiz archives. It also explains how to use advanced +features like image compression, automatic deletion of archives, and automated +cryptographic signing of quiz archives. +If you have problems installing the Quiz Archiver or have further questions, +please feel free to open an issue within the +[GitHub issue tracker](https://github.com/ngandrass/moodle-quiz_archiver/issues). -#### Automatic archive signing -If enabled, new archives will be automatically signed during creation. TSP data -can be accessed via the _Show details_ button of an archive job on the quiz -archiver overview page. Existing archives will not be signed automatically (see -[Manual archive signing](#manual-archive-signing)). +## Versioning and Compatibility -#### Manual archive signing +The [quiz_archiver Moodle Plugin](https://github.com/ngandrass/moodle-quiz_archiver) +and its corresponding [Quiz Archive Worker](https://github.com/ngandrass/moodle-quiz-archive-worker) +both use [Semantic Versioning 2.0.0](https://semver.org/). -To manually sign a quiz archive, navigate to the quiz archiver overview page, -click the _Show details_ button for the desired archive job, and click the -_Sign archive now_ button. +This means that their version numbers are structured as `MAJOR.MINOR.PATCH`. The +Moodle plugin and the archive worker service are compatible as long as they use +the same `MAJOR` version number. Minor and patch versions can differ between the +two components without breaking compatibility. -#### Validating an archive and its signature +However, it is **recommended to always use the latest version** of both the +Moodle plugin and the archive worker service to ensure you get all the latest +bug fixes, features, and optimizations. -To validate an archive and its signature, install `openssl` and follow these -steps: -1. Obtain the certificate files from your TSP authority (`.crt` and `.pem`) -2. Navigate to the quiz archiver overview page and click the _Show details_ - button for the desired archive job -3. Download the archive and both TSP signature files (`.tsq` and `.tsr`) -4. Inspect TSP response to see time stamp and signed hash value - 1. Execute: `openssl ts -reply -in .tsr -text` -5. Verify the quiz archive against the TSP response. This process confirms that - the archive was signed by the TSP authority and that the archive was not - modified after signing, i.e., the hash values of the file matches the TSP - response. - 1. Execute: `openssl ts -verify -in .tsr -data .tar.gz -CAfile .pem -untrusted .crt` - 2. Verify that the output is `Verification: OK` \ - Errors are indicated by `Verification: FAILED` -6. (Optional) Verify that TSP request and TSP response match - 1. Execute: `openssl ts -verify -in .tsr -queryfile .tsq -CAfile .pem -untrusted .crt` - 2. Verify that the output is `Verification: OK` \ - Errors are indicated by `Verification: FAILED` +### Compatibility Examples +| Moodle Plugin | Archive Worker | Compatible | +|---------------|----------------|------------| +| 1.0.0 | 1.0.0 | Yes | +| 1.2.3 | 1.0.0 | Yes | +| 1.0.0 | 1.1.2 | Yes | +| 2.1.4 | 2.0.1 | Yes | +| | | | +| 2.0.0 | 1.0.0 | No | +| 1.0.0 | 2.0.0 | No | +| 2.4.2 | 1.4.2 | No | -## Testing -For testing, a Moodle course that contains a reference quiz is provided. The quiz -features an instance of every standard question type that is provided by Moodle. -Example students are enrolled and possess graded attempts, ready to test the -archive functionality. +### Development / Testing Versions -You can import the reference course from the corresponding Moodle backup file -located at [res/backup-moodle2-course-qa-ref.mbz](res/backup-moodle2-course-qa-ref.mbz). +Special development versions, used for testing, can be created but will never be +published to the Moodle plugin directory. Such development versions are marked +by a `+dev-[TIMESTAMP]` suffix, e.g., `2.4.2+dev-2022010100`. ------ ## Screenshots ### Quiz Archiver overview page -![Image of quiz archiver overview page](doc/screenshots/quiz_archiver_overview_page.png) +![Image of quiz archiver overview page](docs/assets/screenshots/quiz_archiver_overview_page.png) ### New job queued while another job is running -![Image of new job queued while another job is running](doc/screenshots/quiz_archiver_new_job_queued.png) +![Image of new job queued while another job is running](docs/assets/screenshots/quiz_archiver_new_job_queued.png) ### Quiz archive job details -![Image of quiz archive job details](doc/screenshots/quiz_archiver_job_details_modal.png) +![Image of quiz archive job details](docs/assets/screenshots/quiz_archiver_job_details_modal.png) ### Example of PDF report (excerpts) -![Image of example of PDF report (extract): Header](doc/screenshots/quiz_archiver_report_example_pdf_header.png) -![Image of example of PDF report (extract): Question 1](doc/screenshots/quiz_archiver_report_example_pdf_question_1.png) -![Image of example of PDF report (extract): Question 2](doc/screenshots/quiz_archiver_report_example_pdf_question_2.png) -![Image of example of PDF report (extract): Question 3](doc/screenshots/quiz_archiver_report_example_pdf_question_3.png) +![Image of example of PDF report (extract): Header](docs/assets/screenshots/quiz_archiver_report_example_pdf_header.png) +![Image of example of PDF report (extract): Question 1](docs/assets/screenshots/quiz_archiver_report_example_pdf_question_1.png) +![Image of example of PDF report (extract): Question 2](docs/assets/screenshots/quiz_archiver_report_example_pdf_question_2.png) +![Image of example of PDF report (extract): Question 3](docs/assets/screenshots/quiz_archiver_report_example_pdf_question_3.png) ------ ## License diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..90d9cb5 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,3 @@ +# Testing + +See: https://quizarchiver.gandrass.de/development/unittests/ diff --git a/adminui/autoinstall.php b/adminui/autoinstall.php new file mode 100644 index 0000000..ff76cf9 --- /dev/null +++ b/adminui/autoinstall.php @@ -0,0 +1,100 @@ +. + +/** + * Handler for autoinstall feature from the admin UI of the quiz archiver plugin. + * + * @package quiz_archiver + * @copyright 2024 Niels Gandraß + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +require_once(__DIR__.'/../../../../../config.php'); +require_once("{$CFG->libdir}/moodlelib.php"); +require_once("{$CFG->dirroot}/mod/quiz/report/archiver/classes/form/autoinstall_form.php"); + +use quiz_archiver\form\autoinstall_form; +use quiz_archiver\local\autoinstall; + +// Disable error reporting to prevent warning of potential redefinition of constants. +$olderrorreporting = error_reporting(); +error_reporting(0); + +/** @var bool Disables output buffering */ +const NO_OUTPUT_BUFFERING = true; + +error_reporting($olderrorreporting); + +// Ensure user has permissions. +require_login(); +$ctx = context_system::instance(); +require_capability('moodle/site:config', $ctx); + +// Setup page. +$PAGE->set_context($ctx); +$PAGE->set_url('/mod/quiz/report/archiver/adminui/autoinstall.php'); +$title = get_string('autoinstall_plugin', 'quiz_archiver'); +$PAGE->set_title($title); + +$returnlink = html_writer::link( + new moodle_url('/admin/settings.php', ['section' => 'quiz_archiver_settings']), + get_string('back') +); + +echo $OUTPUT->header(); +echo $OUTPUT->heading($title); + +// Content. +if (autoinstall::plugin_is_unconfigured()) { + $form = new autoinstall_form(); + + if ($form->is_cancelled()) { + // Cancelled. + echo '

'.get_string('autoinstall_cancelled', 'quiz_archiver').'

'; + echo '

'.$returnlink.'

'; + } else if ($data = $form->get_data()) { + // Perform autoinstall. + list($success, $log) = autoinstall::execute( + $data->workerurl, + $data->wsname, + $data->rolename, + $data->username + ); + + // Show result. + echo '

'.get_string('autoinstall_started', 'quiz_archiver').'

'; + echo '

'.get_string('logs').'

'; + echo "
{$log}

"; + + if ($success) { + echo '

'.get_string('autoinstall_success', 'quiz_archiver').'

'; + } else { + echo '

'.get_string('autoinstall_failure', 'quiz_archiver').'

'; + } + + echo '

'.$returnlink.'

'; + } else { + echo '

'.get_string('autoinstall_explanation', 'quiz_archiver').'

'; + echo '

'.get_string('autoinstall_explanation_details', 'quiz_archiver').'

'; + $form->display(); + } +} else { + echo '

'.get_string('autoinstall_already_configured_long', 'quiz_archiver').'

'; + echo '

'.$returnlink.'

'; +} + +// End page. +echo $OUTPUT->footer(); diff --git a/classes/ArchiveJob.php b/classes/ArchiveJob.php index c6a7fd7..0066cd8 100644 --- a/classes/ArchiveJob.php +++ b/classes/ArchiveJob.php @@ -26,7 +26,9 @@ use quiz_archiver\local\util; -defined('MOODLE_INTERNAL') || die(); +// @codingStandardsIgnoreLine +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore + /** @@ -39,13 +41,13 @@ class ArchiveJob { /** @var string UUID of the job, as assigned by the archive worker */ protected string $jobid; /** @var int ID of the course this job is associated with */ - protected int $course_id; + protected int $courseid; /** @var int ID of the course module this job is associated with */ - protected int $cm_id; + protected int $cmid; /** @var int ID of the quiz this job is associated with */ - protected int $quiz_id; + protected int $quizid; /** @var int ID of the user that owns this job */ - protected int $user_id; + protected int $userid; /** @var int Unix timestamp of job creation */ protected int $timecreated; /** @var int|null Unix timestamp after which this jobs artifacts will be deleted automatically. Null indicates no deletion.*/ @@ -54,7 +56,7 @@ class ArchiveJob { protected string $wstoken; /** @var ?TSPManager A Time-Stamp Protocol (TSP) manager associated with this class */ - protected ?TSPManager $tspManager; + protected ?TSPManager $tspmanager; /** @var string Name of the job status table */ const JOB_TABLE_NAME = 'quiz_archiver_jobs'; @@ -65,7 +67,7 @@ class ArchiveJob { /** @var string Name of the table to store attemptids and userids */ const ATTEMPTS_TABLE_NAME = 'quiz_archiver_attempts'; - // Job status values + // Job status values. /** @var string Job status: Unknown */ const STATUS_UNKNOWN = 'UNKNOWN'; /** @var string Job status: Uninitialized */ @@ -74,6 +76,10 @@ class ArchiveJob { const STATUS_AWAITING_PROCESSING = 'AWAITING_PROCESSING'; /** @var string Job status: Running */ const STATUS_RUNNING = 'RUNNING'; + /** @var string Job status: Waiting for backup */ + const STATUS_WAITING_FOR_BACKUP = 'WAITING_FOR_BACKUP'; + /** @var string Job status: Finalizing */ + const STATUS_FINALIZING = 'FINALIZING'; /** @var string Job status: Finished */ const STATUS_FINISHED = 'FINISHED'; /** @var string Job status: Failed */ @@ -108,6 +114,7 @@ class ArchiveJob { 'username', 'firstname', 'lastname', + 'idnumber', 'timestart', 'timefinish', 'date', @@ -123,36 +130,36 @@ class ArchiveJob { * * @param int $id ID of the job inside the database * @param string $jobid UUID of the job, as assigned by the archive worker - * @param int $course_id ID of the course this job is associated with - * @param int $cm_id ID of the course module this job is associated with - * @param int $quiz_id ID of the quiz this job is associated with - * @param int $user_id ID of the user that owns this job + * @param int $courseid ID of the course this job is associated with + * @param int $cmid ID of the course module this job is associated with + * @param int $quizid ID of the quiz this job is associated with + * @param int $userid ID of the user that owns this job * @param int $timecreated Unix timestamp of job creation * @param ?int $retentiontime Unix timestamp after which this jobs * artifacts will be deleted automatically. Null indicates no deletion. * @param string $wstoken The webservice token that is allowed to write to this job via API */ protected function __construct( - int $id, + int $id, string $jobid, - int $course_id, - int $cm_id, - int $quiz_id, - int $user_id, - int $timecreated, - ?int $retentiontime, + int $courseid, + int $cmid, + int $quizid, + int $userid, + int $timecreated, + ?int $retentiontime, string $wstoken ) { $this->id = $id; $this->jobid = $jobid; - $this->course_id = $course_id; - $this->cm_id = $cm_id; - $this->quiz_id = $quiz_id; - $this->user_id = $user_id; + $this->courseid = $courseid; + $this->cmid = $cmid; + $this->quizid = $quizid; + $this->userid = $userid; $this->timecreated = $timecreated; $this->retentiontime = $retentiontime; $this->wstoken = $wstoken; - $this->tspManager = null; // Lazy initialization + $this->tspmanager = null; // Lazy initialization. } /** @@ -161,23 +168,23 @@ protected function __construct( * @return TSPManager The TSPManager for this ArchiveJob * @throws \dml_exception If the plugin config could not be loaded */ - public function TSPManager(): TSPManager { - if ($this->tspManager == null) { - $this->tspManager = new TSPManager($this); + public function tspmanager(): TSPManager { + if ($this->tspmanager == null) { + $this->tspmanager = new TSPManager($this); } - return $this->tspManager; + return $this->tspmanager; } /** * Creates a new job inside the database * * @param string $jobid UUID of the job, as assigned by the archive worker - * @param int $course_id ID of the course this job is associated with - * @param int $cm_id ID of the course module this job is associated with - * @param int $quiz_id ID of the quiz this job is associated with - * @param int $user_id ID of the user that initiated this job - * @param ?int $retention_seconds Number of seconds to retain this jobs + * @param int $courseid ID of the course this job is associated with + * @param int $cmid ID of the course module this job is associated with + * @param int $quizid ID of the quiz this job is associated with + * @param int $userid ID of the user that initiated this job + * @param ?int $retentionseconds Number of seconds to retain this jobs * artifact after job creation. Null indicates no deletion. * @param string $wstoken The webservice token that is allowed to write to this job via API * @param array $attempts List of quiz attempts to archive, each consisting of an attemptid and a userid @@ -190,14 +197,14 @@ public function TSPManager(): TSPManager { */ public static function create( string $jobid, - int $course_id, - int $cm_id, - int $quiz_id, - int $user_id, - ?int $retention_seconds, + int $courseid, + int $cmid, + int $quizid, + int $userid, + ?int $retentionseconds, string $wstoken, - array $attempts, - array $settings, + array $attempts, + array $settings, string $status = self::STATUS_UNKNOWN ): ArchiveJob { global $DB; @@ -207,41 +214,41 @@ public static function create( throw new \moodle_exception('encryption_keyalreadyexists'); } - // Create database entry and return ArchiveJob object to represent it + // Create database entry and return ArchiveJob object to represent it. $now = time(); - $retentiontime = $retention_seconds ? $now + $retention_seconds : null; + $retentiontime = $retentionseconds ? $now + $retentionseconds : null; $id = $DB->insert_record(self::JOB_TABLE_NAME, [ 'jobid' => $jobid, - 'courseid' => $course_id, - 'cmid' => $cm_id, - 'quizid' => $quiz_id, - 'userid' => $user_id, + 'courseid' => $courseid, + 'cmid' => $cmid, + 'quizid' => $quizid, + 'userid' => $userid, 'status' => $status, 'timecreated' => $now, 'timemodified' => $now, 'retentiontime' => $retentiontime, - 'wstoken' => $wstoken + 'wstoken' => $wstoken, ]); - // Store job settings + // Store job settings. $DB->insert_records(self::JOB_SETTINGS_TABLE_NAME, array_map(function($key, $value) use ($id): array { return [ 'jobid' => $id, 'settingkey' => strval($key), - 'settingvalue' => $value === null ? null : strval($value) + 'settingvalue' => $value === null ? null : strval($value), ]; }, array_keys($settings), $settings)); - // Remember attempts associated with this archive + // Remember attempts associated with this archive. $DB->insert_records(self::ATTEMPTS_TABLE_NAME, array_map(function($data) use ($id): array { return [ 'jobid' => $id, 'userid' => $data->userid, - 'attemptid' => $data->attemptid + 'attemptid' => $data->attemptid, ]; }, $attempts)); - return new ArchiveJob($id, $jobid, $course_id, $cm_id, $quiz_id, $user_id, $now, $retentiontime, $wstoken); + return new ArchiveJob($id, $jobid, $courseid, $cmid, $quizid, $userid, $now, $retentiontime, $wstoken); } /** @@ -308,18 +315,18 @@ protected static function exists_in_db(string $jobid): bool { /** * Returns all ArchiveJobs that match given selectors. * - * @param int $course_id - * @param int $cm_id - * @param int $quiz_id - * @return array - * @throws \dml_exception + * @param int $courseid ID of the course to query for + * @param int $cmid ID of the course module to query for + * @param int $quizid ID of the quiz to query for + * @return array List of ArchiveJobs that match the given selectors + * @throws \dml_exception if the database query fails */ - public static function get_jobs(int $course_id, int $cm_id, int $quiz_id): array { + public static function get_jobs(int $courseid, int $cmid, int $quizid): array { global $DB; $records = $DB->get_records(self::JOB_TABLE_NAME, [ - 'courseid' => $course_id, - 'cmid' => $cm_id, - 'quizid' => $quiz_id + 'courseid' => $courseid, + 'cmid' => $cmid, + 'quizid' => $quizid, ]); return array_map(fn($dbdata): ArchiveJob => new ArchiveJob( @@ -342,14 +349,14 @@ public static function get_jobs(int $course_id, int $cm_id, int $quiz_id): array * This is the preferred way to access status of ALL jobs, instead of using * ArchiveJob::get_jobs() and call get_status() on each job individually! * - * @param int $course_id - * @param int $cm_id - * @param int $quiz_id + * @param int $courseid + * @param int $cmid + * @param int $quizid * @return array * @throws \dml_exception * @throws \coding_exception */ - public static function get_metadata_for_jobs(int $course_id, int $cm_id, int $quiz_id): array { + public static function get_metadata_for_jobs(int $courseid, int $cmid, int $quizid): array { global $DB; $records = $DB->get_records_sql( 'SELECT '. @@ -358,8 +365,8 @@ public static function get_metadata_for_jobs(int $course_id, int $cm_id, int $qu ' u.firstname AS userfirstname, u.lastname AS userlastname, u.username, '. ' c.fullname AS coursename, '. ' q.name as quizname '. - 'FROM {quiz_archiver_jobs} AS j '. - ' LEFT JOIN {quiz_archiver_tsp} AS tsp ON j.id = tsp.jobid '. + 'FROM {quiz_archiver_jobs} j '. + ' LEFT JOIN {quiz_archiver_tsp} tsp ON j.id = tsp.jobid '. ' LEFT JOIN {user} u ON j.userid = u.id '. ' LEFT JOIN {course} c ON j.courseid = c.id '. ' LEFT JOIN {quiz} q ON j.quizid = q.id '. @@ -368,15 +375,15 @@ public static function get_metadata_for_jobs(int $course_id, int $cm_id, int $qu ' j.cmid = :cmid AND '. ' j.quizid = :quizid ', [ - 'courseid' => $course_id, - 'cmid' => $cm_id, - 'quizid' => $quiz_id + 'courseid' => $courseid, + 'cmid' => $cmid, + 'quizid' => $quizid, ] ); return array_values(array_map(function($j): array { - // Get artifactfile metadata if available - $artifactfile_metadata = null; + // Get artifactfile metadata if available. + $artifactfilemetadata = null; if ($j->artifactfileid) { $artifactfile = get_file_storage()->get_file_by_id($j->artifactfileid); if ($artifactfile) { @@ -390,17 +397,17 @@ public static function get_metadata_for_jobs(int $course_id, int $cm_id, int $qu true ); - $artifactfile_metadata = [ + $artifactfilemetadata = [ 'name' => $artifactfile->get_filename(), 'downloadurl' => $artifactfileurl->out(), 'size' => $artifactfile->get_filesize(), 'size_human' => display_size($artifactfile->get_filesize()), - 'checksum' => $j->artifactfilechecksum + 'checksum' => $j->artifactfilechecksum, ]; } } - // Prepate TSP data + // Prepate TSP data. $tspdata = null; if ($j->tsp_timecreated && $j->artifactfileid) { $tspdata = [ @@ -423,56 +430,59 @@ public static function get_metadata_for_jobs(int $course_id, int $cm_id, int $qu $artifactfile->get_filepath()."{$j->id}/", FileManager::TSP_DATA_REPLY_FILENAME, true - )->out() + )->out(), ]; } - // Calculate autodelete metadata + // Calculate autodelete metadata. if ($j->retentiontime !== null) { if ($j->status == self::STATUS_DELETED) { - $autodelete_str = get_string('archive_deleted', 'quiz_archiver'); - } elseif ($j->retentiontime <= time()) { - $autodelete_str = get_string('archive_autodelete_now', 'quiz_archiver'); + $autodeletestr = get_string('archive_deleted', 'quiz_archiver'); + } else if ($j->retentiontime <= time()) { + $autodeletestr = get_string('archive_autodelete_now', 'quiz_archiver'); } else { - $autodelete_str = get_string( + $autodeletestr = get_string( 'archive_autodelete_in', 'quiz_archiver', util::duration_to_human_readable($j->retentiontime - time()) ); - $autodelete_str .= ' ('.userdate($j->retentiontime, get_string('strftimedatetime', 'core_langconfig')).')'; + $autodeletestr .= ' ('.userdate($j->retentiontime, get_string('strftimedatetime', 'core_langconfig')).')'; } } else { - $autodelete_str = get_string('archive_autodelete_disabled', 'quiz_archiver'); + $autodeletestr = get_string('archive_autodelete_disabled', 'quiz_archiver'); } - // Build job metadata array + // Build job metadata array. return [ 'id' => $j->id, 'jobid' => $j->jobid, 'status' => $j->status, - 'status_display_args' => self::get_status_display_args($j->status), + 'status_display_args' => self::get_status_display_args( + $j->status, + $j->statusextras ? json_decode($j->statusextras, true) : null + ), 'timecreated' => $j->timecreated, 'timemodified' => $j->timemodified, 'retentiontime' => $j->retentiontime, 'autodelete' => $j->retentiontime !== null, 'autodelete_done' => $j->status == self::STATUS_DELETED ? true : null, - 'autodelete_str' => $autodelete_str, + 'autodelete_str' => $autodeletestr, 'user' => [ 'id' => $j->userid, 'firstname' => $j->userfirstname, 'lastname' => $j->userlastname, - 'username' => $j->username + 'username' => $j->username, ], 'course' => [ 'id' => $j->courseid, - 'name' => $j->coursename + 'name' => $j->coursename, ], 'quiz' => [ 'id' => $j->quizid, 'cmid' => $j->cmid, - 'name' => $j->quizname + 'name' => $j->quizname, ], - 'artifactfile' => $artifactfile_metadata, + 'artifactfile' => $artifactfilemetadata, 'tsp' => $tspdata, 'settings' => self::convert_archive_settings_for_display( (new self($j->id, '', -1, -1, -1, -1, -1, null, ''))->get_settings() @@ -515,14 +525,14 @@ public static function delete_expired_artifacts(): int { 'id' ); - $files_deleted = 0; + $numfilesdeleted = 0; foreach ($records as $record) { $job = self::get_by_id($record->id); $job->delete_artifact(); - $files_deleted++; + $numfilesdeleted++; } - return $files_deleted; + return $numfilesdeleted; } /** @@ -534,7 +544,7 @@ public static function delete_expired_artifacts(): int { public function delete(): void { global $DB; - // Delete additional data + // Delete additional data. $this->delete_webservice_token(); $this->delete_temporary_files(); $this->delete_artifact(); @@ -542,33 +552,33 @@ public function delete(): void { $DB->delete_records(self::JOB_SETTINGS_TABLE_NAME, ['jobid' => $this->id]); $DB->delete_records(self::ATTEMPTS_TABLE_NAME, ['jobid' => $this->id]); - // Delete job from DB + // Delete job from DB. $DB->delete_records(self::JOB_TABLE_NAME, ['id' => $this->id]); - // Invalidate self + // Invalidate self. $this->id = -1; $this->jobid = ''; - $this->course_id = -1; - $this->cm_id = -1; - $this->quiz_id = -1; - $this->user_id = -1; + $this->courseid = -1; + $this->cmid = -1; + $this->quizid = -1; + $this->userid = -1; $this->wstoken = ''; } /** * Marks this job as timeouted if it is overdue * - * @param int $timeout_min Minutes until a job is considered as timeouted after creation + * @param int $timeoutmin Minutes until a job is considered as timeouted after creation * @return bool True if the job was overdue * @throws \dml_exception */ - public function timeout_if_overdue(int $timeout_min): bool { + public function timeout_if_overdue(int $timeoutmin): bool { if ($this->is_complete()) { return false; } - // Check if job is overdue - if ($this->timecreated < (time() - ($timeout_min * 60))) { + // Check if job is overdue. + if ($this->timecreated < (time() - ($timeoutmin * 60))) { $this->set_status(self::STATUS_TIMEOUT); return true; } else { @@ -639,8 +649,8 @@ public function get_jobid(): string { * * @return int ID of the course this job is associated with */ - public function get_course_id(): int { - return $this->course_id; + public function get_courseid(): int { + return $this->courseid; } /** @@ -648,8 +658,8 @@ public function get_course_id(): int { * * @return int ID of the course module this job is associated with */ - public function get_cm_id(): int { - return $this->cm_id; + public function get_cmid(): int { + return $this->cmid; } /** @@ -657,8 +667,8 @@ public function get_cm_id(): int { * * @return int ID of the quiz this job is associated with */ - public function get_quiz_id(): int { - return $this->quiz_id; + public function get_quizid(): int { + return $this->quizid; } /** @@ -666,8 +676,8 @@ public function get_quiz_id(): int { * * @return int ID of the user that owns this job */ - public function get_user_id(): int { - return $this->user_id; + public function get_userid(): int { + return $this->userid; } /** @@ -684,31 +694,43 @@ public function get_retentiontime(): ?int { * Updates the status of this ArchiveJob * * @param string $status New job status - * @param bool $delete_wstoken_if_completed If true, delete associated wstoken + * @param array|null $statusextras Optional additional status information + * @param bool $deletewstokenifcompleted If true, delete associated wstoken * if this status change completed the job - * @param bool $delete_temporary_files_if_completed If true, all linked + * @param bool $deletetemporaryfilesifcompleted If true, all linked * temporary files will be deleted if this status change completed the job * @return void * @throws \dml_exception on failure */ public function set_status( string $status, - bool $delete_wstoken_if_completed = true, - bool $delete_temporary_files_if_completed = true + ?array $statusextras = null, + bool $deletewstokenifcompleted = true, + bool $deletetemporaryfilesifcompleted = true ): void { global $DB; + + // Prepare statusextras data. + $statusextrasjson = null; + if ($statusextras !== null) { + $statusextrasjson = json_encode($statusextras); + } + + // Update status in database. $DB->update_record(self::JOB_TABLE_NAME, (object) [ 'id' => $this->id, 'status' => $status, + 'statusextras' => $statusextrasjson, 'timemodified' => time(), ]); + // Handle post status change actions. if ($this->is_complete()) { - if ($delete_wstoken_if_completed) { + if ($deletewstokenifcompleted) { $this->delete_webservice_token(); } - if ($delete_temporary_files_if_completed) { + if ($deletetemporaryfilesifcompleted) { $this->delete_temporary_files(); } } @@ -728,34 +750,115 @@ public function get_status(): string { } } + /** + * Retrieves the statusextras of this job + * + * @return array|null Additional status information of this job, if available + */ + public function get_statusextras(): ?array { + global $DB; + try { + $statusextras = $DB->get_field(self::JOB_TABLE_NAME, 'statusextras', ['jobid' => $this->jobid], MUST_EXIST); + if ($statusextras) { + return json_decode($statusextras, true); + } else { + return null; + } + } catch (\dml_exception $e) { + return null; + } + } + /** * Returns the status indicator display arguments based on the given job status * * @param string $status JOB_STATUS value to convert + * @param array|null $statusextras Additional status information to display * @return array Status of this job, translated for display * @throws \coding_exception */ - public static function get_status_display_args(string $status): array { + public static function get_status_display_args(string $status, ?array $statusextras = null): array { + // Translate status to display text and color. switch ($status) { case self::STATUS_UNKNOWN: - return ['color' => 'warning', 'text' => get_string('job_status_UNKNOWN', 'quiz_archiver')]; + $res = [ + 'color' => 'warning', + 'text' => get_string('job_status_UNKNOWN', 'quiz_archiver'), + 'help' => get_string('job_status_UNKNOWN_help', 'quiz_archiver'), + ]; + break; case self::STATUS_UNINITIALIZED: - return ['color' => 'secondary', 'text' => get_string('job_status_UNINITIALIZED', 'quiz_archiver')]; + $res = [ + 'color' => 'secondary', + 'text' => get_string('job_status_UNINITIALIZED', 'quiz_archiver'), + 'help' => get_string('job_status_UNINITIALIZED_help', 'quiz_archiver'), + ]; + break; case self::STATUS_AWAITING_PROCESSING: - return ['color' => 'secondary', 'text' => get_string('job_status_AWAITING_PROCESSING', 'quiz_archiver')]; + $res = [ + 'color' => 'secondary', + 'text' => get_string('job_status_AWAITING_PROCESSING', 'quiz_archiver'), + 'help' => get_string('job_status_AWAITING_PROCESSING_help', 'quiz_archiver'), + ]; + break; case self::STATUS_RUNNING: - return ['color' => 'primary', 'text' => get_string('job_status_RUNNING', 'quiz_archiver')]; + $res = [ + 'color' => 'primary', + 'text' => get_string('job_status_RUNNING', 'quiz_archiver'), + 'help' => get_string('job_status_RUNNING_help', 'quiz_archiver'), + ]; + break; + case self::STATUS_WAITING_FOR_BACKUP: + $res = [ + 'color' => 'info', + 'text' => get_string('job_status_WAITING_FOR_BACKUP', 'quiz_archiver'), + 'help' => get_string('job_status_WAITING_FOR_BACKUP_help', 'quiz_archiver'), + ]; + break; + case self::STATUS_FINALIZING: + $res = [ + 'color' => 'info', + 'text' => get_string('job_status_FINALIZING', 'quiz_archiver'), + 'help' => get_string('job_status_FINALIZING_help', 'quiz_archiver'), + ]; + break; case self::STATUS_FINISHED: - return ['color' => 'success', 'text' => get_string('job_status_FINISHED', 'quiz_archiver')]; + $res = [ + 'color' => 'success', + 'text' => get_string('job_status_FINISHED', 'quiz_archiver'), + 'help' => get_string('job_status_FINISHED_help', 'quiz_archiver'), + ]; + break; case self::STATUS_FAILED: - return ['color' => 'danger', 'text' => get_string('job_status_FAILED', 'quiz_archiver')]; + $res = [ + 'color' => 'danger', + 'text' => get_string('job_status_FAILED', 'quiz_archiver'), + 'help' => get_string('job_status_FAILED_help', 'quiz_archiver'), + ]; + break; case self::STATUS_TIMEOUT: - return ['color' => 'danger', 'text' => get_string('job_status_TIMEOUT', 'quiz_archiver')]; + $res = [ + 'color' => 'danger', + 'text' => get_string('job_status_TIMEOUT', 'quiz_archiver'), + 'help' => get_string('job_status_TIMEOUT_help', 'quiz_archiver'), + ]; + break; case self::STATUS_DELETED: - return ['color' => 'secondary', 'text' => get_string('job_status_DELETED', 'quiz_archiver')]; + $res = [ + 'color' => 'secondary', + 'text' => get_string('job_status_DELETED', 'quiz_archiver'), + 'help' => get_string('job_status_DELETED_help', 'quiz_archiver'), + ]; + break; default: - return ['color' => 'light', 'text' => $status]; + $res = ['color' => 'light', 'text' => $status, 'help' => $status]; + break; } + + // Add additional status information if present. + $res['statusextras'] = $statusextras ?? []; + + return $res; } /** @@ -788,11 +891,16 @@ public function get_artifact(): ?\stored_file { global $DB; try { $file = $DB->get_record_sql( - 'SELECT pathnamehash FROM {files} AS files JOIN {'.self::JOB_TABLE_NAME.'} AS jobs ON files.id = jobs.artifactfileid WHERE jobs.id = :id', + 'SELECT pathnamehash '. + 'FROM {files} files '. + 'JOIN {'.self::JOB_TABLE_NAME.'} jobs ON files.id = jobs.artifactfileid '. + 'WHERE jobs.id = :id', ['id' => $this->id] ); - if (!$file) return null; + if (!$file) { + return null; + } return get_file_storage()->get_file_by_hash($file->pathnamehash); } catch (\Exception $e) { @@ -831,7 +939,7 @@ public function has_artifact(): bool { /** * Links the moodle file with the given ID to this job as the artifact * - * @param int $file_id ID of the file from {files} to link to this + * @param int $fileid ID of the file from {files} to link to this * job as the artifact * @param string $checksum Hash of the artifact file contents to store in * the database @@ -839,16 +947,18 @@ public function has_artifact(): bool { * @return bool True on success * @throws \dml_exception */ - public function link_artifact(int $file_id, string $checksum): bool { + public function link_artifact(int $fileid, string $checksum): bool { global $DB; - if ($file_id < 1) return false; + if ($fileid < 1) { + return false; + } $DB->update_record(self::JOB_TABLE_NAME, (object) [ 'id' => $this->id, - 'artifactfileid' => $file_id, + 'artifactfileid' => $fileid, 'artifactfilechecksum' => $checksum, - 'timemodified' => time() + 'timemodified' => time(), ]); return true; @@ -865,13 +975,13 @@ public function delete_artifact(): void { if ($artifact = $this->get_artifact()) { $artifact->delete(); - $this->tspManager()->delete_tsp_data(); + $this->tspmanager()->delete_tsp_data(); $DB->update_record(self::JOB_TABLE_NAME, (object) [ 'id' => $this->id, 'artifactfileid' => null, 'artifactfilechecksum' => null, - 'timemodified' => time() + 'timemodified' => time(), ]); $this->set_status(self::STATUS_DELETED); @@ -900,7 +1010,7 @@ public function link_temporary_file(string $pathnamehash): void { $DB->insert_record(self::FILES_TABLE_NAME, [ 'jobid' => $this->id, - 'pathnamehash' => $pathnamehash + 'pathnamehash' => $pathnamehash, ]); } @@ -914,18 +1024,18 @@ public function delete_temporary_files(): int { global $DB; $fs = get_file_storage(); - $num_deleted_files = 0; + $numdeletedfiles = 0; $tempfiles = $DB->get_records(self::FILES_TABLE_NAME, ['jobid' => $this->id]); foreach ($tempfiles as $tempfile) { $f = $fs->get_file_by_hash($tempfile->pathnamehash); if ($f) { $f->delete(); $DB->delete_records(self::FILES_TABLE_NAME, ['jobid' => $this->id, 'pathnamehash' => $tempfile->pathnamehash]); - $num_deleted_files++; + $numdeletedfiles++; } } - return $num_deleted_files; + return $numdeletedfiles; } /** @@ -938,11 +1048,11 @@ public function get_temporary_files(): array { global $DB; $fs = get_file_storage(); - $fileEntries = $DB->get_records(self::FILES_TABLE_NAME, ['jobid' => $this->id]); + $fileentries = $DB->get_records(self::FILES_TABLE_NAME, ['jobid' => $this->id]); $files = []; - foreach ($fileEntries as $fileEntry) { - $f = $fs->get_file_by_hash($fileEntry->pathnamehash); + foreach ($fileentries as $fileentry) { + $f = $fs->get_file_by_hash($fileentry->pathnamehash); if ($f !== false) { $files[$f->get_id()] = $f; } @@ -967,22 +1077,22 @@ public function delete_webservice_token(): void { * and no orphaned dollar signs * * @param string $pattern Filename pattern to test - * @param array $allowed_variables List of allowed variables + * @param array $allowedvariables List of allowed variables * @return bool True if the pattern is valid */ - protected static function is_valid_filename_pattern(string $pattern, array $allowed_variables): bool { - // Check for minimal length + protected static function is_valid_filename_pattern(string $pattern, array $allowedvariables): bool { + // Check for minimal length. if (strlen($pattern) < 1) { return false; } - // Check for variables - $residue = preg_replace('/\$\{\s*('.implode('|', $allowed_variables).')\s*\}/m', '', $pattern); + // Check for variables. + $residue = preg_replace('/\$\{\s*('.implode('|', $allowedvariables).')\s*\}/m', '', $pattern); if (strpos($residue, '$') !== false) { return false; } - // Check for forbidden characters + // Check for forbidden characters. foreach (self::FILENAME_FORBIDDEN_CHARACTERS as $char) { if (strpos($pattern, $char) !== false) { return false; @@ -1041,12 +1151,12 @@ protected static function sanitize_filename(string $filename): string { * @throws \coding_exception */ public static function generate_archive_filename($course, $cm, $quiz, string $pattern): string { - // Validate pattern + // Validate pattern. if (!self::is_valid_archive_filename_pattern($pattern)) { throw new \invalid_parameter_exception(get_string('error_invalid_archive_filename_pattern', 'quiz_archiver')); } - // Prepare data + // Prepare data. $data = [ 'courseid' => $course->id, 'cmid' => $cm->id, @@ -1056,10 +1166,10 @@ public static function generate_archive_filename($course, $cm, $quiz, string $pa 'quizname' => $quiz->name, 'timestamp' => time(), 'date' => date('Y-m-d'), - 'time' => date('H-i-s') + 'time' => date('H-i-s'), ]; - // Substitute variables + // Substitute variables. $filename = $pattern; foreach ($data as $key => $value) { $filename = preg_replace('/\$\{\s*'.$key.'\s*\}/m', $value, $filename); @@ -1084,13 +1194,13 @@ public static function generate_archive_filename($course, $cm, $quiz, string $pa public static function generate_attempt_filename($course, $cm, $quiz, int $attemptid, string $pattern): string { global $DB; - // Validate pattern + // Validate pattern. if (!self::is_valid_attempt_filename_pattern($pattern)) { throw new \invalid_parameter_exception(get_string('error_invalid_attempt_filename_pattern', 'quiz_archiver')); } - // Prepare data - // We query the DB directly to prevent a full question_attempt object from being created + // Prepare data. + // We query the DB directly to prevent a full question_attempt object from being created. $attemptinfo = $DB->get_record('quiz_attempts', ['id' => $attemptid], '*', MUST_EXIST); $userinfo = $DB->get_record('user', ['id' => $attemptinfo->userid], '*', MUST_EXIST); $data = [ @@ -1109,9 +1219,10 @@ public static function generate_attempt_filename($course, $cm, $quiz, int $attem 'username' => $userinfo->username, 'firstname' => $userinfo->firstname, 'lastname' => $userinfo->lastname, + 'idnumber' => $userinfo->idnumber, ]; - // Substitute variables + // Substitute variables. $filename = $pattern; foreach ($data as $key => $value) { $filename = preg_replace('/\$\{\s*'.$key.'\s*\}/m', $value, $filename); diff --git a/classes/BackupManager.php b/classes/BackupManager.php index 5b90287..8b67e18 100644 --- a/classes/BackupManager.php +++ b/classes/BackupManager.php @@ -29,9 +29,11 @@ use context_course; use context_module; -defined('MOODLE_INTERNAL') || die(); +// @codingStandardsIgnoreLine +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore -require_once($CFG->dirroot.'/backup/util/includes/backup_includes.php'); + +require_once($CFG->dirroot.'/backup/util/includes/backup_includes.php'); // @codeCoverageIgnore /** * Manages everything related to backups via the Moodle Backup API @@ -39,7 +41,7 @@ class BackupManager { /** @var \stdClass Backup controller metadata from DB */ - protected \stdClass $backup_metadata; + protected \stdClass $backupmetadata; /** @var array Define what to include and exclude in backups */ const BACKUP_SETTINGS = [ @@ -71,13 +73,13 @@ class BackupManager { public function __construct(string $backupid) { global $DB; - $this->backup_metadata = $DB->get_record( + $this->backupmetadata = $DB->get_record( 'backup_controllers', ['backupid' => $backupid], 'id, backupid, operation, type, itemid, userid', MUST_EXIST ); - if ($this->backup_metadata->operation != 'backup') { + if ($this->backupmetadata->operation != 'backup') { throw new \ValueError('Only backup operations are supported.'); } } @@ -110,7 +112,16 @@ public function is_failed(): bool { */ public function get_status(): int { global $DB; - return $DB->get_record('backup_controllers', ['id' => $this->backup_metadata->id], 'status')->status; + return $DB->get_record('backup_controllers', ['id' => $this->backupmetadata->id], 'status')->status; + } + + /** + * Retrieves the backupid of this instance + * + * @return string Backup ID + */ + public function get_backupid(): string { + return $this->backupmetadata->backupid; } /** @@ -119,7 +130,16 @@ public function get_status(): int { * @return string Type of this backup controller (e.g. course, activity) */ public function get_type(): string { - return $this->backup_metadata->type; + return $this->backupmetadata->type; + } + + /** + * Retrieves the ID of the user that initiated this backup + * + * @return int User-ID of the backup initiator + */ + public function get_userid(): int { + return $this->backupmetadata->userid; } /** @@ -131,9 +151,9 @@ public function get_type(): string { public function is_associated_with_job(ArchiveJob $job): bool { switch ($this->get_type()) { case backup::TYPE_1ACTIVITY: - return $this->backup_metadata->itemid == $job->get_cm_id(); + return $this->backupmetadata->itemid == $job->get_cmid(); case backup::TYPE_1COURSE: - return $this->backup_metadata->itemid == $job->get_course_id(); + return $this->backupmetadata->itemid == $job->get_courseid(); default: return false; } @@ -144,16 +164,16 @@ public function is_associated_with_job(ArchiveJob $job): bool { * * @param string $type Type of the backup, based on backup::TYPE_* * @param int $id ID of the backup object - * @param int $user_id User-ID to associate this backup with + * @param int $userid User-ID to associate this backup with * @return object Backup metadata object * @throws \base_setting_exception * @throws \base_task_exception * @throws \dml_exception */ - protected static function initiate_backup(string $type, int $id, int $user_id): object { + protected static function initiate_backup(string $type, int $id, int $userid): object { global $CFG; - // Validate type and set variables accordingly + // Validate type and set variables accordingly. switch ($type) { case backup::TYPE_1COURSE: $contextid = context_course::instance($id)->id; @@ -165,40 +185,39 @@ protected static function initiate_backup(string $type, int $id, int $user_id): throw new \ValueError("Backup type not supported"); } - // Initialize backup + // Initialize backup. $bc = new backup_controller( $type, $id, backup::FORMAT_MOODLE, backup::INTERACTIVE_NO, backup::MODE_ASYNC, - $user_id, + $userid, backup::RELEASESESSION_YES ); $backupid = $bc->get_backupid(); $filename = 'quiz_archiver-'.$type.'-backup-'.$id.'-'.date("Ymd-His").'.mbz'; - // Configure backup + // Configure backup. $tasks = $bc->get_plan()->get_tasks(); foreach ($tasks as $task) { if ($task instanceof \backup_root_task) { $task->get_setting('filename')->set_value($filename); - foreach (self::BACKUP_SETTINGS as $setting_name => $setting_value) { - $task->get_setting($setting_name)->set_value($setting_value); + foreach (self::BACKUP_SETTINGS as $name => $value) { + $task->get_setting($name)->set_value($value); } } } - // Enqueue as adhoc task + // Enqueue as adhoc task. $bc->set_status(backup::STATUS_AWAITING); $asynctask = new \core\task\asynchronous_backup_task(); - $asynctask->set_blocking(false); $asynctask->set_custom_data(['backupid' => $backupid]); - $asynctask->set_userid($user_id); + $asynctask->set_userid($userid); \core\task\manager::queue_adhoc_task($asynctask); - // Generate backup file url + // Generate backup file url. $url = strval(\moodle_url::make_webservice_pluginfile_url( $contextid, 'backup', @@ -208,14 +227,14 @@ protected static function initiate_backup(string $type, int $id, int $user_id): $filename )); - $internal_wwwroot = get_config('quiz_archiver')->internal_wwwroot; - if ($internal_wwwroot) { - $url = str_replace(rtrim($CFG->wwwroot, '/'), rtrim($internal_wwwroot, '/'), $url); + $internalwwwroot = get_config('quiz_archiver')->internal_wwwroot; + if ($internalwwwroot) { + $url = str_replace(rtrim($CFG->wwwroot, '/'), rtrim($internalwwwroot, '/'), $url); } return (object) [ 'backupid' => $backupid, - 'userid' => $user_id, + 'userid' => $userid, 'context' => $contextid, 'component' => 'backup', 'filearea' => $type, @@ -227,33 +246,32 @@ protected static function initiate_backup(string $type, int $id, int $user_id): ]; } - /** * Initiates a new quiz backup * - * @param int $cm_id ID of the course module for the quiz - * @param int $user_id User-ID to associate this backup with + * @param int $cmid ID of the course module for the quiz + * @param int $userid User-ID to associate this backup with * @return object Backup metadata object * @throws \base_setting_exception * @throws \base_task_exception * @throws \dml_exception */ - public static function initiate_quiz_backup(int $cm_id, int $user_id): object { - return self::initiate_backup(backup::TYPE_1ACTIVITY, $cm_id, $user_id); + public static function initiate_quiz_backup(int $cmid, int $userid): object { + return self::initiate_backup(backup::TYPE_1ACTIVITY, $cmid, $userid); } /** * Initiates a new course backup * - * @param int $course_id ID of the course module for the quiz - * @param int $user_id User-ID to associate this backup with + * @param int $courseid ID of the course module for the quiz + * @param int $userid User-ID to associate this backup with * @return object Backup metadata object * @throws \base_setting_exception * @throws \base_task_exception * @throws \dml_exception */ - public static function initiate_course_backup(int $course_id, int $user_id): object { - return self::initiate_backup(backup::TYPE_1COURSE, $course_id, $user_id); + public static function initiate_course_backup(int $courseid, int $userid): object { + return self::initiate_backup(backup::TYPE_1COURSE, $courseid, $userid); } } diff --git a/classes/FileManager.php b/classes/FileManager.php index 33355c5..eae53cd 100644 --- a/classes/FileManager.php +++ b/classes/FileManager.php @@ -27,7 +27,9 @@ use context_course; use stored_file; -defined('MOODLE_INTERNAL') || die(); +// @codingStandardsIgnoreLine +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore + /** * Manages everything related to file handling via the Moodle File API. @@ -55,11 +57,11 @@ class FileManager { const ARTIFACT_EXPORT_TEMPFILE_LIFETIME_SECONDS = 86400; /** @var int ID of the course this FileManager is associated with */ - protected int $course_id; + protected int $courseid; /** @var int ID of the course module this FileManager is associated with */ - protected int $cm_id; + protected int $cmid; /** @var int ID of the quiz this FileManager is associated with */ - protected int $quiz_id; + protected int $quizid; /** @var context_course Context of the course this FileManager is associated with */ protected context_course $context; @@ -67,37 +69,37 @@ class FileManager { * Creates a new FileManager instance that is associated with the given quiz, * living inside a course module of a course. * - * @param int $course_id ID of the course - * @param int $cm_id ID of the course module - * @param int $quiz_id ID of the quiz + * @param int $courseid ID of the course + * @param int $cmid ID of the course module + * @param int $quizid ID of the quiz */ - public function __construct(int $course_id, int $cm_id, int $quiz_id) { - $this->course_id = $course_id; - $this->cm_id = $cm_id; - $this->quiz_id = $quiz_id; - $this->context = context_course::instance($course_id); + public function __construct(int $courseid, int $cmid, int $quizid) { + $this->courseid = $courseid; + $this->cmid = $cmid; + $this->quizid = $quizid; + $this->context = context_course::instance($courseid); } /** * Generates a file path based on course, course module, and quiz. If any * part is left empty, the respective partial path is returned. * - * @param int $course_id ID of the course - * @param int $cm_id ID of the course module - * @param int $quiz_id ID of the quiz + * @param int $courseid ID of the course + * @param int $cmid ID of the course module + * @param int $quizid ID of the quiz * @return string Path according to passed IDs */ - public static function get_file_path(int $course_id = -1, int $cm_id = -1, int $quiz_id = -1): string { + public static function get_file_path(int $courseid = -1, int $cmid = -1, int $quizid = -1): string { $path = ''; - if ($course_id > 0) { - $path .= "/$course_id"; + if ($courseid > 0) { + $path .= "/$courseid"; - if ($cm_id > 0) { - $path .= "/$cm_id"; + if ($cmid > 0) { + $path .= "/$cmid"; - if ($quiz_id > 0) { - $path .= "/$quiz_id"; + if ($quizid > 0) { + $path .= "/$quizid"; } } } @@ -110,8 +112,8 @@ public static function get_file_path(int $course_id = -1, int $cm_id = -1, int $ * * @return string Filepath for this FileManager instance */ - protected function get_own_file_path() { - return self::get_file_path($this->course_id, $this->cm_id, $this->quiz_id); + protected function get_own_file_path(): string { + return self::get_file_path($this->courseid, $this->cmid, $this->quizid); } /** @@ -120,32 +122,34 @@ protected function get_own_file_path() { * * @param stored_file $draftfile Archive artifact file, residing inside * 'draft' filearea of the webservice user + * @param int $jobid Internal ID of the job this artifact belongs to. Used + * as itemid for the new stored file * * @return stored_file|null Stored file on success, null on error * * @throws \file_exception * @throws \stored_file_creation_exception */ - public function store_uploaded_artifact(stored_file $draftfile): ?stored_file { - // Check draftfile + public function store_uploaded_artifact(stored_file $draftfile, int $jobid): ?stored_file { + // Check draftfile. if ($draftfile->get_filearea() != "draft" || $draftfile->get_component() != "user") { throw new \file_exception('Passed draftfile does not reside inside the draft area of the webservice user. Aborting'); } - // Create the final stored archive file from draft file + // Create the final stored archive file from draft file. $fs = get_file_storage(); $artifactfile = $fs->create_file_from_storedfile([ 'contextid' => $this->context->id, 'component' => self::COMPONENT_NAME, 'filearea' => self::ARTIFACTS_FILEAREA_NAME, - 'itemid' => 0, + 'itemid' => $jobid, 'filepath' => $this->get_own_file_path(), 'filename' => $draftfile->get_filename(), 'timecreated' => $draftfile->get_timecreated(), 'timemodified' => time(), ], $draftfile); - // Unlink old draft file + // Unlink old draft file. $draftfile->delete(); return $artifactfile; @@ -189,19 +193,19 @@ public static function get_draft_file(int $contextid, int $itemid, string $filep * @return string|null Hexadecimal hash */ public static function hash_file(stored_file $file, string $algo = 'sha256'): ?string { - // Validate requested hash algorithm + // Validate requested hash algorithm. if (!array_search($algo, hash_algos())) { return null; } - // Calculate file hash chunk-wise + // Calculate file hash chunk-wise. $fh = $file->get_content_file_handle(stored_file::FILE_HANDLE_FOPEN); - $hash_ctx = hash_init($algo); + $hashctx = hash_init($algo); while (!feof($fh)) { - hash_update($hash_ctx, fgets($fh, 4096)); + hash_update($hashctx, fgets($fh, 4096)); } - return hash_final($hash_ctx); + return hash_final($hashctx); } /** @@ -251,7 +255,7 @@ public function send_virtual_file(string $filearea, string $relativepath): void * @throws \dml_exception On database error */ protected function send_virtual_file_tsp(string $relativepath): void { - // Validate relativepath + // Validate relativepath. $args = explode('/', $relativepath); if (count($args) !== 6) { throw new \InvalidArgumentException("Invalid relativepath {$relativepath}"); @@ -271,22 +275,23 @@ protected function send_virtual_file_tsp(string $relativepath): void { throw new \InvalidArgumentException("Invalid filename {$filename}"); } - // Get requested data from DB - $job = ArchiveJob::get_by_id($jobid); - if (!$job) { + // Get requested data from DB. + try { + $job = ArchiveJob::get_by_id($jobid); + } catch (\dml_exception $e) { throw new \InvalidArgumentException("Job with ID {$jobid} not found"); } - if ($courseid != $job->get_course_id() || $cmid != $job->get_cm_id() || $quizid != $job->get_quiz_id()) { + if ($courseid != $job->get_courseid() || $cmid != $job->get_cmid() || $quizid != $job->get_quizid()) { throw new \InvalidArgumentException("Invalid resource id in {$relativepath}"); } - $tspdata = $job->TSPManager()->get_tsp_data(); + $tspdata = $job->tspmanager()->get_tsp_data(); if (!$tspdata) { throw new \InvalidArgumentException("No TSP data found for job with ID {$jobid}"); } - // Get requested file contents + // Get requested file contents. switch ($filename) { case self::TSP_DATA_QUERY_FILENAME: $filecontents = $tspdata->query; @@ -300,7 +305,7 @@ protected function send_virtual_file_tsp(string $relativepath): void { throw new \InvalidArgumentException("Invalid filename {$filename}"); } - // Send file to the client + // Send file to the client. \core\session\manager::write_close(); // Unlock session during file serving. ob_clean(); header('Content-Description: File Transfer'); @@ -313,6 +318,12 @@ protected function send_virtual_file_tsp(string $relativepath): void { header('Content-Length: '.strlen($filecontents)); echo $filecontents; ob_flush(); + + // Do not kill tests. + if (PHPUNIT_TEST === true) { + return; + } + die; } @@ -331,20 +342,21 @@ protected function send_virtual_file_tsp(string $relativepath): void { public function extract_attempt_data_from_artifact(stored_file $artifactfile, int $jobid, int $attemptid): ?stored_file { global $CFG; - // Prepare + // Prepare. $packer = get_file_packer('application/x-gzip'); - $workdir = "{$CFG->tempdir}/quiz_archiver/jid{$jobid}_cid{$this->course_id}_cmid{$this->cm_id}_qid{$this->quiz_id}_aid{$attemptid}"; + // @codingStandardsIgnoreLine + $workdir = "{$CFG->tempdir}/quiz_archiver/jid{$jobid}_cid{$this->courseid}_cmid{$this->cmid}_qid{$this->quizid}_aid{$attemptid}"; - // Wrap in try-catch to ensure cleanup on exit + // Wrap in try-catch to ensure cleanup on exit. try { - // Extract metadata file from artifact and find relevant path information + // Extract metadata file from artifact and find relevant path information. $packer->extract_to_pathname($artifactfile, $workdir, [ self::ARTIFACT_METADATA_FILE, ]); $metadata = array_map('str_getcsv', file($workdir."/".self::ARTIFACT_METADATA_FILE)); if ($metadata[0][0] !== 'attemptid' || $metadata[0][9] !== 'path') { - // Fail silently for old archives for now + // Fail silently for old archives for now. if ($metadata[0][9] === 'report_filename') { throw new \invalid_state_exception('Old artifact format is skipped'); } else { @@ -352,10 +364,10 @@ public function extract_attempt_data_from_artifact(stored_file $artifactfile, in } } - // Search for attempt path + // Search for attempt path. $attemptpath = null; foreach ($metadata as $row) { - if ($row[0] == $attemptid) { + if (intval($row[0]) === $attemptid) { $attemptpath = $row[9]; break; } @@ -365,46 +377,47 @@ public function extract_attempt_data_from_artifact(stored_file $artifactfile, in throw new \moodle_exception('Attempt not found in metadata file'); } - // Extract attempt data from artifact + // Extract attempt data from artifact. // All files must be given explicitly to tgz_packer::extract_to_pathname(). Wildcards // are unsupported. Therefore, we list the contents and filter the index. This reduces // space and time complexity compared to extracting the whole archive at once. - $attemptfiles = array_map( + $attemptfiles = array_unique(array_values(array_map( fn($file): string => $file->pathname, - array_filter($packer->list_files($artifactfile), function($file) use ($attemptpath) { + array_filter($packer->list_files($artifactfile), function ($file) use ($attemptpath) { return strpos($file->pathname, ltrim($attemptpath, '/')) === 0; }) - ); + ))); + if (!$packer->extract_to_pathname($artifactfile, $workdir."/attemptdata", $attemptfiles)) { throw new \moodle_exception('Failed to extract attempt data from artifact archive'); } - // Create new archive from extracted attempt data into temp filearea - $export_expiry = time() + self::ARTIFACT_EXPORT_TEMPFILE_LIFETIME_SECONDS; - $export_file = $packer->archive_to_storage( + // Create new archive from extracted attempt data into temp filearea. + $exportexpiry = time() + self::ARTIFACT_EXPORT_TEMPFILE_LIFETIME_SECONDS; + $exportfile = $packer->archive_to_storage( [ - $workdir."/attemptdata" + $workdir."/attemptdata", ], $this->context->id, self::COMPONENT_NAME, self::TEMP_FILEAREA_NAME, 0, - "/{$export_expiry}/", - "attempt_export_jid{$jobid}_cid{$this->course_id}_cmid{$this->cm_id}_qid{$this->quiz_id}_aid{$attemptid}.tar.gz", + "/{$exportexpiry}/", + "attempt_export_jid{$jobid}_cid{$this->courseid}_cmid{$this->cmid}_qid{$this->quizid}_aid{$attemptid}.tar.gz", ); - if (!$export_file) { + if (!$exportfile) { throw new \moodle_exception('Failed to create attempt data archive'); } - return $export_file; + return $exportfile; } catch (\Exception $e) { // Ignore skipped archives but always execute cleanup code! if (!($e instanceof \invalid_state_exception)) { throw $e; } } finally { - // Cleanup + // Cleanup. remove_dir($workdir); } @@ -424,12 +437,12 @@ public function extract_attempt_data_from_artifact(stored_file $artifactfile, in public static function cleanup_temp_files(): int { global $DB; - // Prepare + // Prepare. $fs = get_file_storage(); $now = time(); - $files_deleted = 0; + $numfilesdeleted = 0; - // Query using raw SQL to get temp files independent of contextid to speed this up a LOT + // Query using raw SQL to get temp files independent of contextid to speed this up a LOT. $tempfilerecords = $DB->get_records_sql(" SELECT id, filepath, filesize FROM {files} WHERE component = '".self::COMPONENT_NAME."' @@ -437,7 +450,7 @@ public static function cleanup_temp_files(): int { AND filepath != '/'; "); - // Delete files that are expired (expiry date in path is smaller than now) + // Delete files that are expired (expiry date in path is smaller than now). foreach ($tempfilerecords as $f) { $match = preg_match('/^\/(?P\d+)\/.*$/m', $f->filepath, $matches); if ($match) { @@ -445,13 +458,13 @@ public static function cleanup_temp_files(): int { if ($expiry < $now) { $fs->get_file_by_id($f->id)->delete(); if ($f->filesize > 0) { - $files_deleted++; + $numfilesdeleted++; } } } } - return $files_deleted; + return $numfilesdeleted; } } diff --git a/classes/RemoteArchiveWorker.php b/classes/RemoteArchiveWorker.php index 049714f..9817a2d 100644 --- a/classes/RemoteArchiveWorker.php +++ b/classes/RemoteArchiveWorker.php @@ -26,7 +26,8 @@ use curl; -defined('MOODLE_INTERNAL') || die(); +// @codingStandardsIgnoreLine +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore /** @@ -35,29 +36,29 @@ class RemoteArchiveWorker { /** @var string URL of the remote Quiz Archive Worker instance */ - protected string $server_url; + protected string $serverurl; /** @var int Seconds to wait until a connection can be established before aborting */ - protected int $connection_timeout; + protected int $connectiontimeout; /** @var int Seconds to wait for the request to complete before aborting */ - protected int $request_timeout; + protected int $requesttimeout; /** @var \stdClass Moodle config object for this plugin */ protected \stdClass $config; /** @var int Version of the used API */ - public const API_VERSION = 5; + public const API_VERSION = 6; /** * RemoteArchiveWorker constructor * - * @param string $server_url URL of the remote Archive Worker instance - * @param int $connection_timeout Seconds to wait until a connection can be established before aborting - * @param int $request_timeout Seconds to wait for the request to complete before aborting + * @param string $serverurl URL of the remote Archive Worker instance + * @param int $connectiontimeout Seconds to wait until a connection can be established before aborting + * @param int $requesttimeout Seconds to wait for the request to complete before aborting * @throws \dml_exception If retrieving of the plugin config failed */ - public function __construct(string $server_url, int $connection_timeout, int $request_timeout) { - $this->server_url = $server_url; - $this->connection_timeout = $connection_timeout; - $this->request_timeout = $request_timeout; + public function __construct(string $serverurl, int $connectiontimeout, int $requesttimeout) { + $this->serverurl = $serverurl; + $this->connectiontimeout = $connectiontimeout; + $this->requesttimeout = $requesttimeout; $this->config = get_config('quiz_archiver'); } @@ -68,10 +69,10 @@ public function __construct(string $server_url, int $connection_timeout, int $re * @param int $courseid Moodle course id * @param int $cmid Moodle course module id * @param int $quizid Moodle quiz id - * @param array $job_options Associative array containing global job options - * @param mixed $task_archive_quiz_attempts Array containing payload data for + * @param array $joboptions Associative array containing global job options + * @param mixed $taskarchivequizattempts Array containing payload data for * the archive quiz attempts task, or null if it should not be executed - * @param mixed $task_moodle_backups Array containing payload data for + * @param mixed $taskmoodlebackups Array containing payload data for * the moodle backups task, or null if it should not be executed * * @return mixed Job information returned from the archive worker on success @@ -79,46 +80,57 @@ public function __construct(string $server_url, int $connection_timeout, int $re * service or decoding of the response failed * @throws \RuntimeException if the archive worker service reported an error */ - public function enqueue_archive_job(string $wstoken, int $courseid, int $cmid, int $quizid, array $job_options, $task_archive_quiz_attempts, $task_moodle_backups) { + public function enqueue_archive_job( + string $wstoken, + int $courseid, + int $cmid, + int $quizid, + array $joboptions, + $taskarchivequizattempts, + $taskmoodlebackups + ) { global $CFG; - $moodle_url_base = rtrim($this->config->internal_wwwroot ?: $CFG->wwwroot, '/'); + $moodleurlbase = rtrim($this->config->internal_wwwroot ?: $CFG->wwwroot, '/'); - // Prepare request payload - $request_payload = json_encode(array_merge( + // Prepare request payload. + $payload = json_encode(array_merge( [ - "api_version" => self::API_VERSION, - "moodle_base_url" => $moodle_url_base, - "moodle_ws_url" => $moodle_url_base.'/webservice/rest/server.php', - "moodle_upload_url" => $moodle_url_base.'/webservice/upload.php', - "wstoken" => $wstoken, - "courseid" => $courseid, - "cmid" => $cmid, - "quizid" => $quizid, - "task_archive_quiz_attempts" => $task_archive_quiz_attempts, - "task_moodle_backups" => $task_moodle_backups, + "api_version" => self::API_VERSION, + "moodle_base_url" => $moodleurlbase, + "moodle_ws_url" => $moodleurlbase.'/webservice/rest/server.php', + "moodle_upload_url" => $moodleurlbase.'/webservice/upload.php', + "wstoken" => $wstoken, + "courseid" => $courseid, + "cmid" => $cmid, + "quizid" => $quizid, + "task_archive_quiz_attempts" => $taskarchivequizattempts, + "task_moodle_backups" => $taskmoodlebackups, ], - $job_options + $joboptions )); - // Execute request + // Execute request. // Moodle curl wrapper automatically closes curl handle after requests. No need to call curl_close() manually. - $c = new curl(['ignoresecurity' => true]); // Ignore URL filter since we require custom ports and the URL is only configurable by admins - $result = $c->post($this->server_url, $request_payload, [ - 'CURLOPT_CONNECTTIMEOUT' => $this->connection_timeout, - 'CURLOPT_TIMEOUT' => $this->request_timeout, + // Ignore URL filter since we require custom ports and the URL is only configurable by admins. + $c = new curl(['ignoresecurity' => true]); + $result = $c->post($this->serverurl, $payload, [ + 'CURLOPT_CONNECTTIMEOUT' => $this->connectiontimeout, + 'CURLOPT_TIMEOUT' => $this->requesttimeout, 'CURLOPT_HTTPHEADER' => [ 'Content-Type: application/json', - 'Content-Length: '.strlen($request_payload), - ] + 'Content-Length: '.strlen($payload), + ], ]); - $http_status = $c->get_info()['http_code']; // Invalid PHPDoc in Moodle curl wrapper. Array returned instead of string + $httpstatus = $c->get_info()['http_code']; // Invalid PHPDoc in Moodle curl wrapper. Array returned instead of string. $data = json_decode($result); - // Handle errors - if ($http_status != 200) { + // Handle errors. + // @codingStandardsIgnoreLine + // @codeCoverageIgnoreStart + if ($httpstatus != 200) { if ($data === null) { - throw new \UnexpectedValueException("Decoding of the archive worker response failed. HTTP status code $http_status"); + throw new \UnexpectedValueException("Decoding of the archive worker response failed. HTTP status code $httpstatus"); } throw new \RuntimeException($data->error); } else { @@ -127,8 +139,10 @@ public function enqueue_archive_job(string $wstoken, int $courseid, int $cmid, i } } - // Decoded JSON data containing jobid and job_status returned on success + // Decoded JSON data containing jobid and job_status returned on success. return $data; + // @codingStandardsIgnoreLine + // @codeCoverageIgnoreEnd } } diff --git a/classes/Report.php b/classes/Report.php index bc749ce..f6ce672 100644 --- a/classes/Report.php +++ b/classes/Report.php @@ -27,9 +27,11 @@ use curl; use mod_quiz\quiz_attempt; -defined('MOODLE_INTERNAL') || die(); +// @codingStandardsIgnoreLine +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore -require_once("$CFG->dirroot/mod/quiz/locallib.php"); // Required for legacy mod_quiz functions ... +// Required for legacy mod_quiz functions ... +require_once("$CFG->dirroot/mod/quiz/locallib.php"); // @codeCoverageIgnore /** @@ -102,20 +104,20 @@ public function has_access(string $wstoken): bool { global $DB; try { - // Check if job with given wstoken exists for this quiz + // Check if job with given wstoken exists for this quiz. $jobdata = $DB->get_record(ArchiveJob::JOB_TABLE_NAME, [ 'courseid' => $this->course->id, 'cmid' => $this->cm->id, 'quizid' => $this->quiz->id, - 'wstoken' => $wstoken + 'wstoken' => $wstoken, ], 'status, timecreated', MUST_EXIST); - // Completed / aborted jobs invalidate access + // Completed / aborted jobs invalidate access. if ($jobdata->status == ArchiveJob::STATUS_FINISHED || $jobdata->status == ArchiveJob::STATUS_FAILED) { return false; } - // Job must still be valid + // Job must still be valid. if (($jobdata->timecreated + ($this->config->job_timeout_min * 60)) > time()) { return true; } @@ -150,27 +152,28 @@ public function get_attempts(): array { /** * Gets the metadata of all attempts made inside this quiz, excluding previews. * - * @param array|null $filter_attemptids If given, only attempts with the given + * @param array|null $filterattemptids If given, only attempts with the given * IDs will be returned. * * @return array * @throws \dml_exception */ - public function get_attempts_metadata(array $filter_attemptids = null): array { + public function get_attempts_metadata(?array $filterattemptids = null): array { global $DB; - // Handle attempt ID filter - if ($filter_attemptids) { - $filter_where_clause = "AND qa.id IN (".implode(', ', array_map(fn ($v): string => intval($v), $filter_attemptids)). ")"; + // Handle attempt ID filter. + if ($filterattemptids) { + $filterwhereclause = "AND qa.id IN (".implode(', ', array_map(fn ($v): string => intval($v), $filterattemptids)). ")"; } - // Get all requested attempts + // Get all requested attempts. return $DB->get_records_sql( - "SELECT qa.id AS attemptid, qa.userid, qa.attempt, qa.state, qa.timestart, qa.timefinish, u.username, u.firstname, u.lastname ". + "SELECT qa.id AS attemptid, qa.userid, qa.attempt, qa.state, qa.timestart, qa.timefinish, ". + " u.username, u.firstname, u.lastname, u.idnumber ". "FROM {quiz_attempts} qa LEFT JOIN {user} u ON qa.userid = u.id ". - "WHERE qa.preview = 0 AND qa.quiz = :quizid " . ($filter_where_clause ?? ''), + "WHERE qa.preview = 0 AND qa.quiz = :quizid " . ($filterwhereclause ?? ''), [ - "quizid" => $this->quiz->id + "quizid" => $this->quiz->id, ] ); } @@ -220,7 +223,7 @@ public function get_latest_attempt_for_user($userid): ?int { "LIMIT 1", [ "quizid" => $this->quiz->id, - "userid" => $userid + "userid" => $userid, ] ); @@ -251,26 +254,26 @@ public function attempt_exists(int $attemptid): bool { * Builds the section selection array based on the given archive quiz form * data. * - * @param object $archive_quiz_form_data Data object from a submitted archive_quiz_form + * @param object $archivequizformdata Data object from a submitted archive_quiz_form * @return array Associative array containing the selected sections for export */ - public static function build_report_sections_from_formdata(object $archive_quiz_form_data): array { - // Extract section settings from form data object - $report_sections = []; + public static function build_report_sections_from_formdata(object $archivequizformdata): array { + // Extract section settings from form data object. + $reportsections = []; foreach (self::SECTIONS as $section) { - $report_sections[$section] = $archive_quiz_form_data->{'export_report_section_'.$section}; + $reportsections[$section] = $archivequizformdata->{'export_report_section_'.$section}; } - // Disable all sections that depend on a disabled section + // Disable all sections that depend on a disabled section. foreach (self::SECTION_DEPENDENCIES as $section => $dependencies) { foreach ($dependencies as $dependency) { - if (!$report_sections[$dependency]) { - $report_sections[$section] = 0; + if (!$reportsections[$dependency]) { + $reportsections[$section] = 0; } } } - return $report_sections; + return $reportsections; } /** @@ -285,21 +288,21 @@ public static function build_report_sections_from_formdata(object $archive_quiz_ * @throws \moodle_exception */ public function get_attempt_attachments(int $attemptid): array { - // Prepare + // Prepare. $files = []; $ctx = \context_module::instance($this->cm->id); $attemptobj = quiz_create_attempt_handling_errors($attemptid, $this->cm->id); - // Get all files from all questions inside this attempt + // Get all files from all questions inside this attempt. foreach ($attemptobj->get_slots() as $slot) { $qa = $attemptobj->get_question_attempt($slot); - $qa_files = $qa->get_last_qt_files('attachments', $ctx->id); + $qafiles = $qa->get_last_qt_files('attachments', $ctx->id); - foreach ($qa_files as $qa_file) { + foreach ($qafiles as $qafile) { $files[] = [ 'usageid' => $qa->get_usage_id(), 'slot' => $slot, - 'file' => $qa_file, + 'file' => $qafile, ]; } } @@ -318,7 +321,7 @@ public function get_attempt_attachments(int $attemptid): array { * @throws \dml_exception * @throws \moodle_exception */ - public function get_attempt_attechments_metadata(int $attemptid): array { + public function get_attempt_attachments_metadata(int $attemptid): array { $res = []; foreach ($this->get_attempt_attachments($attemptid) as $attachment) { @@ -326,7 +329,9 @@ public function get_attempt_attechments_metadata(int $attemptid): array { $attachment['file']->get_contextid(), $attachment['file']->get_component(), $attachment['file']->get_filearea(), - "{$attachment['usageid']}/{$attachment['slot']}/{$attachment['file']->get_itemid()}", # YES, this is the abomination of a non-numeric itemid that question_attempt::get_response_file_url() creates and while eating innocent programmers for breakfast ... + "{$attachment['usageid']}/{$attachment['slot']}/{$attachment['file']->get_itemid()}", + /* ^-- YES, this is the abomination of a non-numeric itemid that question_attempt::get_response_file_url() + creates while eating innocent programmers for breakfast ... */ $attachment['file']->get_filepath(), $attachment['file']->get_filename() )); @@ -357,15 +362,17 @@ public function get_attempt_attechments_metadata(int $attemptid): array { * @throws \moodle_exception */ public function generate(int $attemptid, array $sections): string { - global $DB, $PAGE; + global $CFG, $DB, $PAGE; $ctx = \context_module::instance($this->cm->id); $renderer = $PAGE->get_renderer('mod_quiz'); $html = ''; - // Get quiz data and determine state / elapsed time + // Get quiz data and determine state / elapsed time. $attemptobj = quiz_create_attempt_handling_errors($attemptid, $this->cm->id); $attempt = $attemptobj->get_attempt(); $quiz = $attemptobj->get_quiz(); + $quba = \question_engine::load_questions_usage_by_activity($attemptobj->get_uniqueid()); + $quba->preload_all_step_users(); $options = \mod_quiz\question\display_options::make_from_quiz($this->quiz, quiz_attempt_state($quiz, $attempt)); $options->flags = quiz_get_flag_option($attempt, $ctx); $overtime = 0; @@ -384,67 +391,76 @@ public function generate(int $attemptid, array $sections): string { $timetaken = get_string('unfinished', 'quiz'); } - // ##### Section: Quiz header ##### + // Section: Quiz header. if ($sections['header']) { + $quizheaderdata = []; - $quiz_header_data = []; - $attempt_user = $DB->get_record('user', ['id' => $attemptobj->get_userid()]); - $userpicture = new \user_picture($attempt_user); + // User name and link. + $attemptuser = $DB->get_record('user', ['id' => $attemptobj->get_userid()]); + $userpicture = new \user_picture($attemptuser); $userpicture->courseid = $attemptobj->get_courseid(); - $quiz_header_data['user'] = [ - 'title' => $userpicture, - 'content' => new \action_link( - new \moodle_url('/user/view.php', ['id' => $attempt_user->id, 'course' => $attemptobj->get_courseid()]), - fullname($attempt_user, true) - ), + $userlink = new \action_link( + new \moodle_url('/user/view.php', ['id' => $attemptuser->id, 'course' => $attemptobj->get_courseid()]), + fullname($attemptuser, true) + ); + global $OUTPUT; + $quizheaderdata['user'] = [ + 'title' => get_string('user'), + 'content' => $OUTPUT->render($userpicture) . ' ' . $OUTPUT->render($userlink), + ]; + + // User ID number. + $quizheaderdata['useridnumber'] = [ + 'title' => get_string('idnumber'), + 'content' => $attemptuser->idnumber ?: ''.get_string('none').'', ]; - // Quiz metadata - $quiz_header_data['course'] = [ + // Quiz metadata. + $quizheaderdata['course'] = [ 'title' => get_string('course'), - 'content' => $this->course->fullname . ' (Course-ID: ' . $this->course->id . ')' + 'content' => $this->course->fullname . ' (Course-ID: ' . $this->course->id . ')', ]; - $quiz_header_data['quiz'] = [ + $quizheaderdata['quiz'] = [ 'title' => get_string('modulename', 'quiz'), - 'content' => $this->quiz->name . ' (Quiz-ID: ' . $this->quiz->id . ')' + 'content' => $this->quiz->name . ' (Quiz-ID: ' . $this->quiz->id . ')', ]; // Timing information. - $quiz_header_data['startedon'] = [ + $quizheaderdata['startedon'] = [ 'title' => get_string('startedon', 'quiz'), 'content' => userdate($attempt->timestart), ]; - $quiz_header_data['state'] = [ + $quizheaderdata['state'] = [ 'title' => get_string('attemptstate', 'quiz'), 'content' => quiz_attempt::state_name($attempt->state), ]; if ($attempt->state == quiz_attempt::FINISHED) { - $quiz_header_data['completedon'] = [ + $quizheaderdata['completedon'] = [ 'title' => get_string('completedon', 'quiz'), 'content' => userdate($attempt->timefinish), ]; - $quiz_header_data['timetaken'] = [ - 'title' => get_string('timetaken', 'quiz'), + $quizheaderdata['timetaken'] = [ + 'title' => get_string('attemptduration', 'quiz'), 'content' => $timetaken, ]; } if (!empty($overtime)) { - $quiz_header_data['overdue'] = [ + $quizheaderdata['overdue'] = [ 'title' => get_string('overdue', 'quiz'), 'content' => $overtime, ]; } - // Grades + // Grades. $grade = quiz_rescale_grade($attempt->sumgrades, $quiz, false); if ($options->marks >= \question_display_options::MARK_AND_MAX && quiz_has_grades($quiz)) { if (is_null($grade)) { - $quiz_header_data['grade'] = [ - 'title' => get_string('grade', 'quiz'), + $quizheaderdata['grade'] = [ + 'title' => get_string('gradenoun'), 'content' => quiz_format_grade($quiz, $grade), ]; } @@ -455,7 +471,7 @@ public function generate(int $attemptid, array $sections): string { $a = new \stdClass(); $a->grade = quiz_format_grade($quiz, $attempt->sumgrades); $a->maxgrade = quiz_format_grade($quiz, $quiz->sumgrades); - $quiz_header_data['marks'] = [ + $quizheaderdata['marks'] = [ 'title' => get_string('marks', 'quiz'), 'content' => get_string('outofshort', 'quiz', $a), ]; @@ -472,42 +488,49 @@ public function generate(int $attemptid, array $sections): string { } else { $formattedgrade = get_string('outof', 'quiz', $a); } - $quiz_header_data['grade'] = [ - 'title' => get_string('grade', 'quiz'), + $quizheaderdata['grade'] = [ + 'title' => get_string('gradenoun'), 'content' => $formattedgrade, ]; } } // Any additional summary data from the behaviour. - $quiz_header_data = array_merge($quiz_header_data, $attemptobj->get_additional_summary_data($options)); + $quizheaderdata = array_merge($quizheaderdata, $attemptobj->get_additional_summary_data($options)); // Feedback if there is any, and the user is allowed to see it now. if ($sections['quiz_feedback']) { $feedback = $attemptobj->get_overall_feedback($grade); - if ($options->overallfeedback && $feedback) { - $quiz_header_data['feedback'] = [ - 'title' => get_string('feedback', 'quiz'), - 'content' => $feedback, - ]; - } + $quizheaderdata['feedback'] = [ + 'title' => get_string('feedback', 'quiz'), + 'content' => $feedback ?: ''.get_string('none').'', + ]; } - // Add export date - $quiz_header_data['exportdate'] = [ + // Add export date. + $quizheaderdata['exportdate'] = [ 'title' => get_string('archived', 'quiz_archiver'), 'content' => userdate(time()), ]; - // Add summary table to the html - $html .= $renderer->review_summary_table($quiz_header_data, 0); + // Add summary table to the html. + if ($CFG->branch <= 403) { + // TODO (MDL-0): Remove after Moodle 4.1 (LTS) support ends on 2025-12-08. + $html .= $renderer->review_summary_table($quizheaderdata, 0); + } else { + // TODO (MDL-0): Rework into proper use of new 4.4 API but create appropriate test cases first. + $html .= $renderer->review_attempt_summary( + \mod_quiz\output\attempt_summary_information::create_from_legacy_array($quizheaderdata), + 0 + ); + } } - // ##### Section: Quiz questions ##### + // Section: Quiz questions. if ($sections['question']) { $slots = $attemptobj->get_slots(); foreach ($slots as $slot) { - // Define display options for this question + // Define display options for this question. $originalslot = $attemptobj->get_original_slot($slot); $number = $attemptobj->get_question_number($originalslot); $displayoptions = $attemptobj->get_display_options_with_edit_link(true, $slot, ""); @@ -523,12 +546,11 @@ public function generate(int $attemptid, array $sections): string { $displayoptions->flags = 1; $displayoptions->manualcommentlink = 0; - // Render question as HTML + // Render question as HTML. if ($slot != $originalslot) { $attemptobj->get_question_attempt($slot)->set_max_mark( $attemptobj->get_question_attempt($originalslot)->get_max_mark()); } - $quba = \question_engine::load_questions_usage_by_activity($attemptobj->get_uniqueid()); $html .= $quba->render_question($slot, $displayoptions, $number); } } @@ -542,11 +564,11 @@ public function generate(int $attemptid, array $sections): string { * * @param int $attemptid ID of the attempt this report is for * @param array $sections Array of self::SECTIONS to include in the report - * @param bool $fix_relative_urls If true, all relative URLs will be + * @param bool $fixrelativeurls If true, all relative URLs will be * forcefully mapped to the Moodle base URL * @param bool $minimal If true, unneccessary elements (e.g. navbar) are * stripped from the generated HTML DOM - * @param bool $inline_images If true, all images will be inlined as base64 + * @param bool $inlineimages If true, all images will be inlined as base64 * to prevent rendering issues on user side * * @return string HTML DOM of the rendered quiz attempt report @@ -556,61 +578,67 @@ public function generate(int $attemptid, array $sections): string { * @throws \moodle_exception * @throws \DOMException */ - public function generate_full_page(int $attemptid, array $sections, bool $fix_relative_urls = true, bool $minimal = true, bool $inline_images = true): string { + public function generate_full_page( + int $attemptid, + array $sections, + bool $fixrelativeurls = true, + bool $minimal = true, + bool $inlineimages = true + ): string { global $CFG, $OUTPUT; - // Build HTML tree + // Build HTML tree. $html = ""; $html .= $OUTPUT->header(); $html .= self::generate($attemptid, $sections); $html .= $OUTPUT->footer(); - // Parse HTML as DOMDocument but supress consistency check warnings + // Parse HTML as DOMDocument but supress consistency check warnings. libxml_use_internal_errors(true); $dom = new \DOMDocument(); $dom->loadHTML($html); libxml_clear_errors(); - // Patch relative URLs - if ($fix_relative_urls) { - $baseNode = $dom->createElement("base"); - $baseNode->setAttribute("href", $CFG->wwwroot); - $dom->getElementsByTagName('head')[0]->appendChild($baseNode); + // Patch relative URLs. + if ($fixrelativeurls) { + $basenode = $dom->createElement("base"); + $basenode->setAttribute("href", $CFG->wwwroot); + $dom->getElementsByTagName('head')[0]->appendChild($basenode); } - // Cleanup DOM if desired + // Cleanup DOM if desired. if ($minimal) { - // We need to inject custom CSS to hide elements since the DOM generated by + // We need to inject custom CSS to hide elements since the DOM generated by. // Moodle can be corrupt which causes the PHP DOMDocument parser to die... - $cssHacksNode = $dom->createElement("style", " + $csshacksnode = $dom->createElement("style", " nav.navbar { display: none !important; } - + footer { display: none !important; } - + div#page { margin-top: 0 !important; padding-left: 0 !important; padding-right: 0 !important; height: initial !important; } - + div#page-wrapper { height: initial !important; } - + .stackinputerror { display: none !important; } "); - $dom->getElementsByTagName('head')[0]->appendChild($cssHacksNode); + $dom->getElementsByTagName('head')[0]->appendChild($csshacksnode); } - // Convert all local images to base64 if desired - if ($inline_images) { + // Convert all local images to base64 if desired. + if ($inlineimages) { foreach ($dom->getElementsByTagName('img') as $img) { if (!$this->convert_image_to_base64($img)) { $img->setAttribute('x-debug-inlining-failed', 'true'); @@ -621,6 +649,7 @@ public function generate_full_page(int $attemptid, array $sections, bool $fix_re return $dom->saveHTML(); } + // @codingStandardsIgnoreStart /** @var string Regex for URLs of qtype_stack plots */ const REGEX_MOODLE_URL_STACKPLOT = '/^(?Phttps?:\/\/.+)?(\/question\/type\/stack\/plot\.php\/)(?P[^\/\#\?\&]+\.(png|svg))$/m'; @@ -632,6 +661,7 @@ public function generate_full_page(int $attemptid, array $sections, bool $fix_re /** @var string Regex for Moodle theme image files */ const REGEX_MOODLE_URL_THEME_IMAGE = '/^(?Phttps?:\/\/.+)?(\/theme\/image\.php\/)(?P[^\/]+)\/(?P[^\/]+)\/(?P[^\/]+)\/(?P.+)$/m'; + // @codingStandardsIgnoreEnd /** @var string[] Mapping of file extensions to file types that are allowed to process */ const ALLOWED_IMAGE_TYPES = [ @@ -643,7 +673,7 @@ public function generate_full_page(int $attemptid, array $sections, bool $fix_re 'webp' => 'image/webp', 'bmp' => 'image/bmp', 'ico' => 'image/x-icon', - 'tiff' => 'image/tiff' + 'tiff' => 'image/tiff', ]; /** @@ -657,7 +687,7 @@ public function generate_full_page(int $attemptid, array $sections, bool $fix_re protected function convert_image_to_base64(\DOMElement $img): bool { global $CFG; - // Only process images with src attribute + // Only process images with src attribute. if (!$img->getAttribute('src')) { $img->setAttribute('x-debug-notice', 'no source present'); return false; @@ -665,115 +695,133 @@ protected function convert_image_to_base64(\DOMElement $img): bool { $img->setAttribute('x-original-source', $img->getAttribute('src')); } - // Remove any parameters and anchors from URL - $img_src = preg_replace('/^([^\?\&\#]+).*$/', '${1}', $img->getAttribute('src')); + // Remove any parameters and anchors from URL. + $imgsrc = preg_replace('/^([^\?\&\#]+).*$/', '${1}', $img->getAttribute('src')); - // Convert relative URLs to absolute URLs + // Convert relative URLs to absolute URLs. $config = get_config('quiz_archiver'); - $moodle_baseurl = rtrim($config->internal_wwwroot ?: $CFG->wwwroot, '/').'/'; + $moodlebaseurl = rtrim($config->internal_wwwroot ?: $CFG->wwwroot, '/').'/'; if ($config->internal_wwwroot) { - $img_src = str_replace(rtrim($CFG->wwwroot, '/'), rtrim($config->internal_wwwroot, '/'), $img_src); + $imgsrc = str_replace(rtrim($CFG->wwwroot, '/'), rtrim($config->internal_wwwroot, '/'), $imgsrc); } - $img_src_url = $this->ensure_absolute_url($img_src, $moodle_baseurl); + $imgsrcurl = $this->ensure_absolute_url($imgsrc, $moodlebaseurl); - # Make sure to only process web URLs and nothing that somehow remained a valid local filepath - if (!substr($img_src_url, 0, 4) === "http") { // Yes, this includes https as well ;) + // Make sure to only process web URLs and nothing that somehow remained a valid local filepath. + if (!substr($imgsrcurl, 0, 4) === "http") { // Yes, this includes https as well ;). $img->setAttribute('x-debug-notice', 'not a web URL'); return false; } - // Only process allowed image types - $img_ext = pathinfo($img_src_url, PATHINFO_EXTENSION); - if (!array_key_exists($img_ext, self::ALLOWED_IMAGE_TYPES)) { - // Edge case: Moodle theme images must not always contain extensions - if (!preg_match(self::REGEX_MOODLE_URL_THEME_IMAGE, $img_src_url)) { + // Only process allowed image types. + $imgext = strtolower(pathinfo($imgsrcurl, PATHINFO_EXTENSION)); + if (!array_key_exists($imgext, self::ALLOWED_IMAGE_TYPES)) { + // Edge case: Moodle theme images must not always contain extensions. + if (!preg_match(self::REGEX_MOODLE_URL_THEME_IMAGE, $imgsrcurl)) { $img->setAttribute('x-debug-notice', 'image type not allowed'); return false; } } - // Try to get image content based on link type - $regex_matches = null; - $img_data = null; + // Try to get image content based on link type. + $regexmatches = null; + $imgdata = null; + $imgmime = array_key_exists($imgext, self::ALLOWED_IMAGE_TYPES) ? self::ALLOWED_IMAGE_TYPES[$imgext] : null; - // Handle special internal URLs first - $is_internal_url = substr($img_src_url, 0, strlen($moodle_baseurl)) === $moodle_baseurl; - if ($is_internal_url) { - if (preg_match(self::REGEX_MOODLE_URL_PLUGINFILE, $img_src_url, $regex_matches)) { - // ### Link type: Moodle pluginfile URL ### + // Handle special internal URLs first. + $isinternalurl = substr($imgsrcurl, 0, strlen($moodlebaseurl)) === $moodlebaseurl; + if ($isinternalurl) { + if (preg_match(self::REGEX_MOODLE_URL_PLUGINFILE, $imgsrcurl, $regexmatches)) { + // Link type: Moodle pluginfile URL. $img->setAttribute('x-url-type', 'MOODLE_URL_PLUGINFILE'); - // Edge case detection: question / qtype files follow another pattern, inserting questionbank_id and question_slot after filearea ... - if ($regex_matches['component'] == 'question' || strpos($regex_matches['component'], 'qtype_') === 0) { - $regex_matches = null; - if (!preg_match(self::REGEX_MOODLE_URL_PLUGINFILE_QUESTION_AND_QTYPE, $img_src_url, $regex_matches)) { + // Edge case detection: question / qtype files follow another pattern, + // inserting questionbank_id and question_slot after filearea ... + if ($regexmatches['component'] == 'question' || strpos($regexmatches['component'], 'qtype_') === 0) { + $regexmatches = null; + if (!preg_match(self::REGEX_MOODLE_URL_PLUGINFILE_QUESTION_AND_QTYPE, $imgsrcurl, $regexmatches)) { $img->setAttribute('x-url-type', 'MOODLE_URL_PLUGINFILE_QUESTION_AND_QTYPE'); return false; } } - // Get file content via Moodle File API + // Decode RFC 3986 URL escaped sequences. + $regexmatches['filename'] = urldecode($regexmatches['filename']); + + // Get file content via Moodle File API. $fs = get_file_storage(); $file = $fs->get_file( - $regex_matches['contextid'], - $regex_matches['component'], - $regex_matches['filearea'], - !empty($regex_matches['itemid']) ? $regex_matches['itemid'] : 0, - '/', // Dirty simplification but works for now *sigh* - $regex_matches['filename'] + $regexmatches['contextid'], + $regexmatches['component'], + $regexmatches['filearea'], + !empty($regexmatches['itemid']) ? $regexmatches['itemid'] : 0, + '/', // Dirty simplification but works for now *sigh*. + $regexmatches['filename'], ); if (!$file) { $img->setAttribute('x-debug-notice', 'moodledata file not found'); return false; } - $img_data = $file->get_content(); - } else if (preg_match(self::REGEX_MOODLE_URL_STACKPLOT, $img_src_url, $regex_matches)) { - // ### Link type: qtype_stack plotfile ### + $imgdata = $file->get_content(); + } else if (preg_match(self::REGEX_MOODLE_URL_STACKPLOT, $imgsrcurl, $regexmatches)) { + // Link type: qtype_stack plotfile. $img->setAttribute('x-url-type', 'MOODLE_URL_STACKPLOT'); - // Get STACK plot file from disk - $filename = $CFG->dataroot . '/stack/plots/' . clean_filename($regex_matches['filename']); + // Decode RFC 3986 URL escaped sequences. + $regexmatches['filename'] = urldecode($regexmatches['filename']); + + // Get STACK plot file from disk. + $filename = $CFG->dataroot . '/stack/plots/' . clean_filename($regexmatches['filename']); if (!is_readable($filename)) { $img->setAttribute('x-debug-notice', 'stack plot file not readable'); return false; } - $img_data = file_get_contents($filename); + $imgdata = file_get_contents($filename); } else { $img->setAttribute('x-debug-internal-url-without-handler', ''); } } - // Fall back to generic URL handling if image data not already set by internal handling routines - if ($img_data === null) { - if (preg_match(self::REGEX_MOODLE_URL_THEME_IMAGE, $img_src_url)) { - // ### Link type: Moodle theme image ### - // We should be able to download there images using a simple HTTP request - // Accessing them directly from disk is a little more complicated due to caching and other logic (see: /theme/image.php). + // Fall back to generic URL handling if image data not already set by internal handling routines. + if ($imgdata === null) { + if (preg_match(self::REGEX_MOODLE_URL_THEME_IMAGE, $imgsrcurl)) { + // Link type: Moodle theme image. + // We should be able to download there images using a simple HTTP request. + // Accessing them directly from disk is a little more complicated due to + // caching and other logic (see: /theme/image.php). // Let's try to keep it this way until we encounter explicit problems. $img->setAttribute('x-url-type', 'MOODLE_URL_THEME_IMAGE'); } else { - // ### Link type: Generic ### + // Link type: Generic. $img->setAttribute('x-url-type', 'GENERIC'); } - // No special local file access. Try to download via HTTP request - $c = new curl(['ignoresecurity' => $is_internal_url]); - $img_data = $c->get($img_src_url); // Curl handle automatically closed - if ($c->get_info()['http_code'] !== 200 || $img_data === false) { - $img->setAttribute('x-debug-more', $img_data); + // No special local file access. Try to download via HTTP request. + $c = new curl(['ignoresecurity' => $isinternalurl]); + $imgdata = $c->get($imgsrcurl); // Curl handle automatically closed. + if ($c->get_info()['http_code'] !== 200 || $imgdata === false) { + $img->setAttribute('x-debug-more', $imgdata); $img->setAttribute('x-debug-notice', 'HTTP request failed'); return false; } + + // Check if we need to detect mime type from response headers. + if (!$imgmime) { + $imgmime = $c->get_info()['content_type']; + if (!in_array($imgmime, self::ALLOWED_IMAGE_TYPES)) { + $img->setAttribute('x-debug-notice', 'image type from response header is not allowed'); + return false; + } + } } - // Encode and replace image if present - if (!$img_data) { + // Encode and replace image if present. + if (!$imgdata) { $img->setAttribute('x-debug-notice', 'no image data'); return false; } - $img_base64 = base64_encode($img_data); - $img->setAttribute('src', 'data:'.self::ALLOWED_IMAGE_TYPES[$img_ext].';base64,'.$img_base64); + $imgbase64 = base64_encode($imgdata); + $img->setAttribute('src', 'data:'.$imgmime.';base64,'.$imgbase64); return true; } @@ -788,35 +836,40 @@ protected function convert_image_to_base64(\DOMElement $img): bool { * @return string Absolute URL */ protected static function ensure_absolute_url(string $url, string $base): string { - /* return if already absolute URL */ + // Return if already absolute URL. if (parse_url($url, PHP_URL_SCHEME) != '') { return $url; } - /* queries and anchors */ + // Queries and anchors. if ($url[0] == '#' || $url[0] == '?') { return $base.$url; } - /* parse base URL and convert to local variables: $scheme, $host, $path */ - extract(parse_url($base)); + // Parse base URL and convert to local variables: $scheme, $host, $path. + $urlparsed = parse_url($base); + $scheme = $urlparsed['scheme']; + $host = $urlparsed['host']; + $path = $urlparsed['path']; - /* remove non-directory element from path */ + // Remove non-directory element from path. $path = preg_replace('#/[^/]*$#', '', $path); - /* destroy path if relative url points to root */ + // Destroy path if relative url points to root. if ($url[0] == '/') { $path = ''; } - /* dirty absolute URL */ + // Dirty absolute URL. $abs = "$host$path/$url"; - /* replace '//' or '/./' or '/foo/../' with '/' */ + // Replace '//' or '/./' or '/foo/../' with '/'. $re = ['#(/\.?/)#', '#/(?!\.\.)[^/]+/\.\./#']; - for ($n = 1; $n > 0; $abs = preg_replace($re, '/', $abs, -1, $n)) {} + for ($n = 1; $n > 0; $abs = preg_replace($re, '/', $abs, -1, $n)) { + continue; + } - /* absolute URL is ready! */ + // Absolute URL is ready! return $scheme.'://'.$abs; } diff --git a/classes/TSPManager.php b/classes/TSPManager.php index c87de8a..fbbeb9f 100644 --- a/classes/TSPManager.php +++ b/classes/TSPManager.php @@ -24,7 +24,9 @@ namespace quiz_archiver; -defined('MOODLE_INTERNAL') || die(); +// @codingStandardsIgnoreLine +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore + /** * Manages all Time-Stamp Protocol (TSP) related tasks for an ArchiveJob. @@ -55,7 +57,7 @@ public function __construct(ArchiveJob $job) { * * @return TimeStampProtocolClient A fresh TimeStampProtocolClient instance */ - protected function getTimestampProtocolClient(): TimeStampProtocolClient { + protected function get_timestampprotocolclient(): TimeStampProtocolClient { return new TimeStampProtocolClient($this->config->tsp_server_url); } @@ -86,11 +88,11 @@ public function wants_tsp_timestamp(): bool { public function has_tsp_timestamp(): bool { global $DB; - $num_tsp_records = $DB->count_records(self::TSP_TABLE_NAME, [ - 'jobid' => $this->job->get_id() + $numtsprecords = $DB->count_records(self::TSP_TABLE_NAME, [ + 'jobid' => $this->job->get_id(), ]); - return $num_tsp_records > 0; + return $numtsprecords > 0; } /** @@ -140,26 +142,26 @@ public function delete_tsp_data(): void { public function timestamp(): void { global $DB; - // Get artifact checksum + // Get artifact checksum. $artifactchecksum = $this->job->get_artifact_checksum(); if ($artifactchecksum === null) { throw new \RuntimeException(get_string('archive_signing_failed_no_artifact', 'quiz_archiver')); } - // Check if TSP signing globally is enabled + // Check if TSP signing globally is enabled. if (!$this->config->tsp_enable) { throw new \Exception(get_string('archive_signing_failed_tsp_disabled', 'quiz_archiver')); } - // Issue TSP timestamp - $tspclient = $this->getTimestampProtocolClient(); + // Issue TSP timestamp. + $tspclient = $this->get_timestampprotocolclient(); $tspdata = $tspclient->sign($artifactchecksum); - // Store TSP data + // Store TSP data. $DB->insert_record(self::TSP_TABLE_NAME, [ 'jobid' => $this->job->get_id(), 'timecreated' => time(), - 'server' => $tspclient->get_server_url(), + 'server' => $tspclient->get_serverurl(), 'timestampquery' => $tspdata['query'], 'timestampreply' => $tspdata['reply'], ]); diff --git a/classes/TimeStampProtocolClient.php b/classes/TimeStampProtocolClient.php index ad3b71d..35ba788 100644 --- a/classes/TimeStampProtocolClient.php +++ b/classes/TimeStampProtocolClient.php @@ -26,7 +26,9 @@ use curl; -defined('MOODLE_INTERNAL') || die(); +// @codingStandardsIgnoreLine +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore + /** * A client implementation for the Time-Stamp Protocol (TSP) as defined by RFC 3161. @@ -36,7 +38,7 @@ class TimeStampProtocolClient { /** @var string URL of the TSP server */ - private string $server_url; + private string $serverurl; /** @var string Content-Type header for TimeStampQuery */ const CONTENT_TYPE_TIMESTAMP_QUERY = 'application/timestamp-query'; @@ -47,10 +49,10 @@ class TimeStampProtocolClient { /** * Creates a new TimeStampProtocolClient instance. * - * @param string $server_url URL of the TSP server + * @param string $serverurl URL of the TSP server */ - public function __construct(string $server_url) { - $this->server_url = $server_url; + public function __construct(string $serverurl) { + $this->serverurl = $serverurl; } /** @@ -58,8 +60,8 @@ public function __construct(string $server_url) { * * @return string URL of the TSP server */ - public function get_server_url() { - return $this->server_url; + public function get_serverurl() { + return $this->serverurl; } /** @@ -72,13 +74,13 @@ public function get_server_url() { * invalid data was received */ public function sign(string $sha256hash): array { - // Prepare TimeStampRequest - $nonce = self::generateNonce(); - $tsreq = self::createTimeStampReq($sha256hash, $nonce); + // Prepare TimeStampRequest. + $nonce = self::generate_nonce(); + $tsreq = self::create_timestamp_request($sha256hash, $nonce); - // Send TimeStampRequest to TSP server + // Send TimeStampRequest to TSP server. $c = new curl(); - $tsresp = $c->post($this->server_url, $tsreq, [ + $tsresp = $c->post($this->serverurl, $tsreq, [ 'CURLOPT_SSL_VERIFYPEER' => true, 'CURLOPT_CONNECTTIMEOUT' => 15, 'CURLOPT_TIMEOUT' => 15, @@ -86,28 +88,27 @@ public function sign(string $sha256hash): array { 'Content-Type: ' . self::CONTENT_TYPE_TIMESTAMP_QUERY, 'Content-Length: ' . strlen($tsreq), ], - ]); - // Error handling - if ($c->error) { // Moodle curl wrapper provides no getter for curl error message + // Error handling. + if ($c->error) { // Moodle curl wrapper provides no getter for curl error message. throw new \Exception(get_string('tsp_client_error_curl', 'quiz_archiver', $c->error)); } else { - $curl_info = $c->get_info(); + $curlinfo = $c->get_info(); } - if ($curl_info['http_code'] !== 200) { - throw new \Exception(get_string('tsp_client_error_http_code', 'quiz_archiver', $curl_info['http_code'])); + if ($curlinfo['http_code'] !== 200) { + throw new \Exception(get_string('tsp_client_error_http_code', 'quiz_archiver', $curlinfo['http_code'])); } - if ($curl_info['content_type'] !== self::CONTENT_TYPE_TIMESTAMP_REPLY) { - throw new \Exception(get_string('tsp_client_error_content_type', 'quiz_archiver', $curl_info['content_type'])); + if ($curlinfo['content_type'] !== self::CONTENT_TYPE_TIMESTAMP_REPLY) { + throw new \Exception(get_string('tsp_client_error_content_type', 'quiz_archiver', $curlinfo['content_type'])); } - // Success + // Success. return [ 'query' => $tsreq, - 'reply' => $tsresp + 'reply' => $tsresp, ]; } @@ -117,7 +118,7 @@ public function sign(string $sha256hash): array { * @return string 128-bit nonce * @throws \Exception If an appropriate source of randomness cannot be found. */ - public static function generateNonce(): string { + public static function generate_nonce(): string { return random_bytes(16); } @@ -129,12 +130,16 @@ public static function generateNonce(): string { * * @param string $sha256hash Hexadecimal SHA256 hash of the data to be signed * @param string $nonce 128-bit nonce to be used in the TimeStampReq - * @param bool $requestTSAPublicKey Whether to request the TSA's public key + * @param bool $requesttsapublickey Whether to request the TSA's public key * @return string ASN.1 encoded TimeStampReq * @throws \ValueError If the SHA256 hash or nonce are invalid */ - protected static function createTimeStampReq(string $sha256hash, string $nonce, bool $requestTSAPublicKey = false): string { - // Validate input + protected static function create_timestamp_request( + string $sha256hash, + string $nonce, + bool $requesttsapublickey = false + ): string { + // Validate input. if (strlen($sha256hash) !== 64) { throw new \ValueError('Invalid hexadecimal SHA256 hash'); } @@ -142,43 +147,43 @@ protected static function createTimeStampReq(string $sha256hash, string $nonce, throw new \ValueError('Invalid nonce'); } - // Generate ASN.1 encoded TimeStampReq + // Generate ASN.1 encoded TimeStampReq. $asn1 = []; - // -> Root DER SEQUENCE - $asn1[0] = chr(0x00) . chr(0x00); // SEQUENCE OF + Length (TBD) - // -> TimeStampRequest Version (INTEGER v1) - $asn1[1] = chr(0x02) . chr(0x01) . chr(0x01); // INTEGER + Length + Value - // -> MessageImprint - $asn1[2] = chr(0x00) . chr(0x00); // SEQUENCE OF + Length (TBD) - $asn1[3] = chr(0x30) . chr(0x0d); // SEQUENCE OF + Length (0x0d == 13) - // -> MessageImprint / Object ID, Length 0x09 - $asn1[4] = chr(0x06) . chr(0x09) // OBJECT IDENTIFIER (length 9 bytes) - . chr(0x60) // 2 . 16 - . chr(0x86) . chr(0x48) // 840 - . chr(0x01) . chr(0x65) // 1 . 101 - . chr(0x03) . chr(0x04) // 3 . 4 - . chr(0x02) . chr(0x01) // 2 . 1 - . chr(0x05) . chr(0x00); // OID Terminator == NULL + Length (0x00) - - // -> MessageImprint / Hash Value, Length 0x40 - $asn1[5] = chr(0x04) . chr(0x20) . hex2bin($sha256hash); // OCTET STRING 0x42 == 32 Bytes (SHA256) + Hash value - - // -> Nonce - $asn1[] = chr(0x02) . chr(0x10) . $nonce; // INTEGER + Length (16 bytes) + nonce value - - // -> certReq - if ($requestTSAPublicKey) { - $asn1[] = chr(0x01) . chr(0x01) . chr(0xff); // BOOLEAN + Length + True + // X-> Root DER SEQUENCE. + $asn1[0] = chr(0x00) . chr(0x00); // SEQUENCE OF + Length (TBD). + // X-> TimeStampRequest Version (INTEGER v1). + $asn1[1] = chr(0x02) . chr(0x01) . chr(0x01); // INTEGER + Length + Value. + // X-> MessageImprint. + $asn1[2] = chr(0x00) . chr(0x00); // SEQUENCE OF + Length (TBD). + $asn1[3] = chr(0x30) . chr(0x0d); // SEQUENCE OF + Length (0x0d == 13). + // X-> MessageImprint / Object ID, Length 0x09. + $asn1[4] = chr(0x06) . chr(0x09) // OBJECT IDENTIFIER (length 9 bytes). + . chr(0x60) // 2 . 16. + . chr(0x86) . chr(0x48) // 840. + . chr(0x01) . chr(0x65) // 1 . 101. + . chr(0x03) . chr(0x04) // 3 . 4. + . chr(0x02) . chr(0x01) // 2 . 1. + . chr(0x05) . chr(0x00); // OID Terminator == NULL + Length (0x00). + + // X-> MessageImprint / Hash Value, Length 0x40. + $asn1[5] = chr(0x04) . chr(0x20) . hex2bin($sha256hash); // OCTET STRING 0x42 == 32 Bytes (SHA256) + Hash value. + + // X-> Nonce. + $asn1[] = chr(0x02) . chr(0x10) . $nonce; // INTEGER + Length (16 bytes) + nonce value. + + // X-> certReq. + if ($requesttsapublickey) { + $asn1[] = chr(0x01) . chr(0x01) . chr(0xff); // BOOLEAN + Length + True. } - // Set correct message length metadata - // -> MessageImprint + // Set correct message length metadata. + // X-> MessageImprint. $asn1[2] = chr(0x30) . chr(strlen($asn1[3] . $asn1[4] . $asn1[5])); - // -> Root DER SEQUENCE + // X-> Root DER SEQUENCE. $asn1[0] = chr(0x30) . chr(strlen(implode('', array_slice($asn1, 1)))); - // Build final ASN.1 encoded TimeStampReq + // Build final ASN.1 encoded TimeStampReq. return implode('', $asn1); } diff --git a/classes/external/generate_attempt_report.php b/classes/external/generate_attempt_report.php index 022f0af..50a4421 100644 --- a/classes/external/generate_attempt_report.php +++ b/classes/external/generate_attempt_report.php @@ -24,8 +24,11 @@ namespace quiz_archiver\external; -// TODO: Remove after deprecation of Moodle 4.1 (LTS) on 08-12-2025 -require_once($CFG->dirroot.'/mod/quiz/report/archiver/patch_401_class_renames.php'); +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore + + +// TODO (MDL-0): Remove after deprecation of Moodle 4.1 (LTS) on 08-12-2025. +require_once($CFG->dirroot.'/mod/quiz/report/archiver/patch_401_class_renames.php'); // @codeCoverageIgnore use core_external\external_api; use core_external\external_function_parameters; @@ -35,8 +38,6 @@ use quiz_archiver\ArchiveJob; use quiz_archiver\Report; -defined('MOODLE_INTERNAL') || die(); - /** * API endpoint to generate a quiz attempt report */ @@ -48,11 +49,31 @@ class generate_attempt_report extends external_api { */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters([ - 'courseid' => new external_value(PARAM_INT, 'ID of course', VALUE_REQUIRED), - 'cmid' => new external_value(PARAM_INT, 'ID of the course module', VALUE_REQUIRED), - 'quizid' => new external_value(PARAM_INT, 'ID of the quiz', VALUE_REQUIRED), - 'attemptid' => new external_value(PARAM_INT, 'ID of the quiz attempt', VALUE_REQUIRED), - 'filenamepattern' => new external_value(PARAM_TEXT, 'Filename pattern to use for the generated archive', VALUE_REQUIRED), + 'courseid' => new external_value( + PARAM_INT, + 'ID of course', + VALUE_REQUIRED + ), + 'cmid' => new external_value( + PARAM_INT, + 'ID of the course module', + VALUE_REQUIRED + ), + 'quizid' => new external_value( + PARAM_INT, + 'ID of the quiz', + VALUE_REQUIRED + ), + 'attemptid' => new external_value( + PARAM_INT, + 'ID of the quiz attempt', + VALUE_REQUIRED + ), + 'filenamepattern' => new external_value( + PARAM_TEXT, + 'Filename pattern to use for the generated archive', + VALUE_REQUIRED + ), 'sections' => new external_single_structure( array_combine(Report::SECTIONS, array_map(fn ($section): external_value => new external_value( @@ -64,7 +85,11 @@ public static function execute_parameters(): external_function_parameters { 'Sections to include in the report', VALUE_REQUIRED ), - 'attachments' => new external_value(PARAM_BOOL, 'Whether to check for attempts and include metadata if present', VALUE_REQUIRED) + 'attachments' => new external_value( + PARAM_BOOL, + 'Whether to check for attempts and include metadata if present', + VALUE_REQUIRED + ), ]); } @@ -74,38 +99,90 @@ public static function execute_parameters(): external_function_parameters { */ public static function execute_returns(): external_single_structure { return new external_single_structure([ - 'courseid' => new external_value(PARAM_INT, 'ID of course', VALUE_OPTIONAL), - 'cmid' => new external_value(PARAM_INT, 'ID of the course module', VALUE_OPTIONAL), - 'quizid' => new external_value(PARAM_INT, 'ID of the quiz', VALUE_OPTIONAL), - 'attemptid' => new external_value(PARAM_INT, 'ID of the quiz attempt', VALUE_OPTIONAL), - 'filename' => new external_value(PARAM_TEXT, 'Desired filename of this quiz attempt report', VALUE_OPTIONAL), - 'report' => new external_value(PARAM_RAW, 'HTML DOM of the generated quiz attempt report', VALUE_OPTIONAL), + 'courseid' => new external_value( + PARAM_INT, + 'ID of course', + VALUE_OPTIONAL + ), + 'cmid' => new external_value( + PARAM_INT, + 'ID of the course module', + VALUE_OPTIONAL + ), + 'quizid' => new external_value( + PARAM_INT, + 'ID of the quiz', + VALUE_OPTIONAL + ), + 'attemptid' => new external_value( + PARAM_INT, + 'ID of the quiz attempt', + VALUE_OPTIONAL + ), + 'filename' => new external_value( + PARAM_TEXT, + 'Desired filename of this quiz attempt report', + VALUE_OPTIONAL + ), + 'report' => new external_value( + PARAM_RAW, + 'HTML DOM of the generated quiz attempt report', + VALUE_OPTIONAL + ), 'attachments' => new external_multiple_structure( new external_single_structure([ - 'slot' => new external_value(PARAM_INT, 'Number of the quiz slot this file is attached to', VALUE_REQUIRED), - 'filename' => new external_value(PARAM_TEXT, 'Filename of the attachment', VALUE_REQUIRED), - 'filesize' => new external_value(PARAM_INT, 'Filesize of the attachment', VALUE_REQUIRED), - 'mimetype' => new external_value(PARAM_TEXT, 'Mimetype of the attachment', VALUE_REQUIRED), - 'contenthash' => new external_value(PARAM_TEXT, 'Contenthash (SHA-1) of the attachment', VALUE_REQUIRED), - 'downloadurl' => new external_value(PARAM_TEXT, 'URL to download the attachment', VALUE_REQUIRED), + 'slot' => new external_value( + PARAM_INT, + 'Number of the quiz slot this file is attached to', + VALUE_REQUIRED + ), + 'filename' => new external_value( + PARAM_TEXT, + 'Filename of the attachment', + VALUE_REQUIRED + ), + 'filesize' => new external_value( + PARAM_INT, + 'Filesize of the attachment', + VALUE_REQUIRED + ), + 'mimetype' => new external_value( + PARAM_TEXT, + 'Mimetype of the attachment', + VALUE_REQUIRED + ), + 'contenthash' => new external_value( + PARAM_TEXT, + 'Contenthash (SHA-1) of the attachment', + VALUE_REQUIRED + ), + 'downloadurl' => new external_value( + PARAM_TEXT, + 'URL to download the attachment', + VALUE_REQUIRED + ), ]), 'Files attached to the quiz attempt', VALUE_OPTIONAL ), - 'status' => new external_value(PARAM_TEXT, 'Status of the executed wsfunction', VALUE_REQUIRED), + 'status' => new external_value( + PARAM_TEXT, + 'Status of the executed wsfunction', + VALUE_REQUIRED + ), ]); } /** * Generate an quiz attempt report as HTML DOM * - * @param int $courseid_raw ID of the course - * @param int $cmid_raw ID of the course module - * @param int $quizid_raw ID of the quiz - * @param int $attemptid_raw ID of the quiz attempt - * @param string $filenamepattern_raw Filename pattern to use for report name generation - * @param array $sections_raw Sections to include in the report - * @param bool $attachments_raw Whether to check for attempts and include metadata if present + * @param int $courseidraw ID of the course + * @param int $cmidraw ID of the course module + * @param int $quizidraw ID of the quiz + * @param int $attemptidraw ID of the quiz attempt + * @param string $filenamepatternraw Filename pattern to use for report name generation + * @param array $sectionsraw Sections to include in the report + * @param bool $attachmentsraw Whether to check for attempts and include metadata if present * * @return array According to execute_returns() * @@ -115,48 +192,55 @@ public static function execute_returns(): external_single_structure { * @throws \DOMException */ public static function execute( - int $courseid_raw, - int $cmid_raw, - int $quizid_raw, - int $attemptid_raw, - string $filenamepattern_raw, - array $sections_raw, - bool $attachments_raw + int $courseidraw, + int $cmidraw, + int $quizidraw, + int $attemptidraw, + string $filenamepatternraw, + array $sectionsraw, + bool $attachmentsraw ): array { - global $DB; + global $DB, $PAGE; - // Validate request + // Validate request. $params = self::validate_parameters(self::execute_parameters(), [ - 'courseid' => $courseid_raw, - 'cmid' => $cmid_raw, - 'quizid' => $quizid_raw, - 'attemptid' => $attemptid_raw, - 'filenamepattern' => $filenamepattern_raw, - 'sections' => $sections_raw, - 'attachments' => $attachments_raw, + 'courseid' => $courseidraw, + 'cmid' => $cmidraw, + 'quizid' => $quizidraw, + 'attemptid' => $attemptidraw, + 'filenamepattern' => $filenamepatternraw, + 'sections' => $sectionsraw, + 'attachments' => $attachmentsraw, ]); - // Check capabilities - $context = \context_module::instance($params['cmid']); + // Check capabilities. + try { + $context = \context_module::instance($params['cmid']); + } catch (\dml_exception $e) { + throw new \invalid_parameter_exception("No module context with given cmid found"); + } require_capability('mod/quiz_archiver:use_webservice', $context); - // Acquire required data objects + // Acquire required data objects. if (!$course = $DB->get_record('course', ['id' => $params['courseid']])) { throw new \invalid_parameter_exception("No course with given courseid found"); } - if (!$cm = get_coursemodule_from_instance("quiz", $params['quizid'], $params['courseid'])) { + if (!$cm = get_coursemodule_from_id("quiz", $params['cmid'])) { + // @codeCoverageIgnoreStart + // This should be covered by the context query above but stays as a safeguard nonetheless. throw new \invalid_parameter_exception("No course module with given cmid found"); + // @codeCoverageIgnoreEnd } if (!$quiz = $DB->get_record('quiz', ['id' => $params['quizid']])) { throw new \invalid_parameter_exception("No quiz with given quizid found"); } - // Validate filename pattern + // Validate filename pattern. if (!ArchiveJob::is_valid_attempt_filename_pattern($params['filenamepattern'])) { throw new \invalid_parameter_exception("Report filename pattern is invalid"); } - // Prepare response + // Prepare response. $res = [ 'courseid' => $params['courseid'], 'cmid' => $params['cmid'], @@ -164,7 +248,14 @@ public static function execute( 'attemptid' => $params['attemptid'], ]; - // Generate report + // Forcefully set URL in $PAGE to the webservice handler to prevent further warnings. + $PAGE->set_url(new \moodle_url('/webservice/rest/server.php', ['wsfunction' => 'quiz_archiver_generate_attempt_report'])); + + // The following code is tested covered by more specific tests. + // @codingStandardsIgnoreLine + // @codeCoverageIgnoreStart + + // Generate report. $report = new Report($course, $cm, $quiz); if (!$report->has_access(optional_param('wstoken', null, PARAM_TEXT))) { return [ @@ -177,20 +268,28 @@ public static function execute( $res['report'] = $report->generate_full_page($params['attemptid'], $params['sections']); - // Check for attachments + // Check for attachments. if ($params['attachments']) { - $res['attachments'] = $report->get_attempt_attechments_metadata($params['attemptid']); + $res['attachments'] = $report->get_attempt_attachments_metadata($params['attemptid']); } else { $res['attachments'] = []; } - // Generate filename - $res['filename'] = ArchiveJob::generate_attempt_filename($course, $cm, $quiz, $params['attemptid'], $params['filenamepattern']); + // Generate filename. + $res['filename'] = ArchiveJob::generate_attempt_filename( + $course, + $cm, + $quiz, + $params['attemptid'], + $params['filenamepattern'] + ); - // Return response + // Return response. $res['status'] = 'OK'; return $res; + // @codingStandardsIgnoreLine + // @codeCoverageIgnoreEnd } } diff --git a/classes/external/get_attempts_metadata.php b/classes/external/get_attempts_metadata.php index 409f2b7..221413b 100644 --- a/classes/external/get_attempts_metadata.php +++ b/classes/external/get_attempts_metadata.php @@ -24,8 +24,11 @@ namespace quiz_archiver\external; -// TODO: Remove after deprecation of Moodle 4.1 (LTS) on 08-12-2025 -require_once($CFG->dirroot.'/mod/quiz/report/archiver/patch_401_class_renames.php'); +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore + + +// TODO (MDL-0): Remove after deprecation of Moodle 4.1 (LTS) on 08-12-2025. +require_once($CFG->dirroot.'/mod/quiz/report/archiver/patch_401_class_renames.php'); // @codeCoverageIgnore use core_external\external_api; use core_external\external_function_parameters; @@ -33,8 +36,7 @@ use core_external\external_single_structure; use core_external\external_value; use quiz_archiver\Report; - -defined('MOODLE_INTERNAL') || die(); +use Random\RandomError; /** * API endpoint to access quiz attempt metadata @@ -47,12 +49,30 @@ class get_attempts_metadata extends external_api { */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters([ - 'courseid' => new external_value(PARAM_INT, 'ID of course', VALUE_REQUIRED), - 'cmid' => new external_value(PARAM_INT, 'ID of the course module', VALUE_REQUIRED), - 'quizid' => new external_value(PARAM_INT, 'ID of the quiz', VALUE_REQUIRED), + 'courseid' => new external_value( + PARAM_INT, + 'ID of course', + VALUE_REQUIRED + ), + 'cmid' => new external_value( + PARAM_INT, + 'ID of the course module', + VALUE_REQUIRED + ), + 'quizid' => new external_value( + PARAM_INT, + 'ID of the quiz', + VALUE_REQUIRED + ), 'attemptids' => new external_multiple_structure( - new external_value(PARAM_INT, 'ID of the quiz attempt', VALUE_REQUIRED) - , 'List of quiz attempt IDs to query', VALUE_REQUIRED), + new external_value( + PARAM_INT, + 'ID of the quiz attempt', + VALUE_REQUIRED + ), + 'List of quiz attempt IDs to query', + VALUE_REQUIRED + ), ]); } @@ -62,33 +82,92 @@ public static function execute_parameters(): external_function_parameters { */ public static function execute_returns(): external_single_structure { return new external_single_structure([ - 'status' => new external_value(PARAM_TEXT, 'Status of the executed wsfunction', VALUE_REQUIRED), - 'courseid' => new external_value(PARAM_INT, 'ID of course', VALUE_OPTIONAL), - 'cmid' => new external_value(PARAM_INT, 'ID of the course module', VALUE_OPTIONAL), - 'quizid' => new external_value(PARAM_INT, 'ID of the quiz', VALUE_OPTIONAL), + 'status' => new external_value( + PARAM_TEXT, + 'Status of the executed wsfunction', + VALUE_REQUIRED + ), + 'courseid' => new external_value( + PARAM_INT, + 'ID of course', + VALUE_OPTIONAL + ), + 'cmid' => new external_value( + PARAM_INT, + 'ID of the course module', + VALUE_OPTIONAL + ), + 'quizid' => new external_value( + PARAM_INT, + 'ID of the quiz', + VALUE_OPTIONAL + ), 'attempts' => new external_multiple_structure( new external_single_structure([ - 'attemptid' => new external_value(PARAM_INT, 'ID of the quiz attempt', VALUE_REQUIRED), - 'userid' => new external_value(PARAM_INT, 'ID of the user for this quit attempt', VALUE_REQUIRED), - 'username' => new external_value(PARAM_TEXT, 'Username for this quiz attempt', VALUE_REQUIRED), - 'firstname' => new external_value(PARAM_TEXT, 'First name for this quiz attempt', VALUE_REQUIRED), - 'lastname' => new external_value(PARAM_TEXT, 'Last name for this quiz attempt', VALUE_REQUIRED), - 'timestart' => new external_value(PARAM_INT, 'Timestamp of when the quiz attempt started', VALUE_REQUIRED), - 'timefinish' => new external_value(PARAM_INT, 'Timestamp of when the quiz attempt finished', VALUE_REQUIRED), - 'attempt' => new external_value(PARAM_INT, 'Sequential attempt number', VALUE_REQUIRED), - 'state' => new external_value(PARAM_TEXT, 'State of the quiz attempt', VALUE_REQUIRED), - ]) - , 'Attempt metadata for each attempt ID', VALUE_OPTIONAL), + 'attemptid' => new external_value( + PARAM_INT, + 'ID of the quiz attempt', + VALUE_REQUIRED + ), + 'userid' => new external_value( + PARAM_INT, + 'ID of the user for this quit attempt', + VALUE_REQUIRED + ), + 'username' => new external_value( + PARAM_TEXT, + 'Username for this quiz attempt', + VALUE_REQUIRED + ), + 'firstname' => new external_value( + PARAM_TEXT, + 'First name for this quiz attempt', + VALUE_REQUIRED + ), + 'lastname' => new external_value( + PARAM_TEXT, + 'Last name for this quiz attempt', + VALUE_REQUIRED + ), + 'idnumber' => new external_value( + PARAM_TEXT, + 'ID number of the user for this quiz attempt', + VALUE_REQUIRED + ), + 'timestart' => new external_value( + PARAM_INT, + 'Timestamp of when the quiz attempt started', + VALUE_REQUIRED + ), + 'timefinish' => new external_value( + PARAM_INT, + 'Timestamp of when the quiz attempt finished', + VALUE_REQUIRED + ), + 'attempt' => new external_value( + PARAM_INT, + 'Sequential attempt number', + VALUE_REQUIRED + ), + 'state' => new external_value( + PARAM_TEXT, + 'State of the quiz attempt', + VALUE_REQUIRED + ), + ]), + 'Attempt metadata for each attempt ID', + VALUE_OPTIONAL + ), ]); } /** * Generate an quiz attempt report as HTML DOM * - * @param int $courseid_raw ID of the course - * @param int $cmid_raw ID of the course module - * @param int $quizid_raw ID of the quiz - * @param array $attemptids_raw IDs of the quiz attempts + * @param int $courseidraw ID of the course + * @param int $cmidraw ID of the course module + * @param int $quizidraw ID of the quiz + * @param array $attemptidsraw IDs of the quiz attempts * * @return array According to execute_returns() * @@ -96,47 +175,54 @@ public static function execute_returns(): external_single_structure { * @throws \dml_transaction_exception * @throws \moodle_exception */ - public static function execute(int $courseid_raw, int $cmid_raw, int $quizid_raw, array $attemptids_raw): array { + public static function execute(int $courseidraw, int $cmidraw, int $quizidraw, array $attemptidsraw): array { global $DB; - // Validate request + // Validate request. $params = self::validate_parameters(self::execute_parameters(), [ - 'courseid' => $courseid_raw, - 'cmid' => $cmid_raw, - 'quizid' => $quizid_raw, - 'attemptids' => $attemptids_raw + 'courseid' => $courseidraw, + 'cmid' => $cmidraw, + 'quizid' => $quizidraw, + 'attemptids' => $attemptidsraw, ]); - // Check capabilities - $context = \context_module::instance($params['cmid']); + // Check capabilities. + try { + $context = \context_module::instance($params['cmid']); + } catch (\dml_exception $e) { + throw new \invalid_parameter_exception("No module context with given cmid found"); + } require_capability('mod/quiz_archiver:use_webservice', $context); - // Acquire required data objects + // Acquire required data objects. if (!$course = $DB->get_record('course', ['id' => $params['courseid']])) { throw new \invalid_parameter_exception("No course with given courseid found"); } - if (!$cm = get_coursemodule_from_instance("quiz", $params['quizid'], $params['courseid'])) { + if (!$cm = get_coursemodule_from_id("quiz", $params['cmid'])) { + // @codeCoverageIgnoreStart + // This should be covered by the context query above but stays as a safeguard nonetheless. throw new \invalid_parameter_exception("No course module with given cmid found"); + // @codeCoverageIgnoreEnd } if (!$quiz = $DB->get_record('quiz', ['id' => $params['quizid']])) { throw new \invalid_parameter_exception("No quiz with given quizid found"); } - // Extract attempt metadata + // Extract attempt metadata. $report = new Report($course, $cm, $quiz); if (!$report->has_access(optional_param('wstoken', null, PARAM_TEXT))) { return [ - 'status' => 'E_ACCESS_DENIED' + 'status' => 'E_ACCESS_DENIED', ]; } - $attempt_metadata = $report->get_attempts_metadata($params['attemptids']); + $attemptmetadata = $report->get_attempts_metadata($params['attemptids']); return [ 'courseid' => $params['courseid'], 'cmid' => $params['cmid'], 'quizid' => $params['quizid'], - 'attempts' => $attempt_metadata, - 'status' => 'OK' + 'attempts' => $attemptmetadata, + 'status' => 'OK', ]; } diff --git a/classes/external/get_backup_status.php b/classes/external/get_backup_status.php index 7e6859f..c95186c 100644 --- a/classes/external/get_backup_status.php +++ b/classes/external/get_backup_status.php @@ -24,8 +24,11 @@ namespace quiz_archiver\external; -// TODO: Remove after deprecation of Moodle 4.1 (LTS) on 08-12-2025 -require_once($CFG->dirroot.'/mod/quiz/report/archiver/patch_401_class_renames.php'); +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore + + +// TODO (MDL-0): Remove after deprecation of Moodle 4.1 (LTS) on 08-12-2025. +require_once($CFG->dirroot.'/mod/quiz/report/archiver/patch_401_class_renames.php'); // @codeCoverageIgnore use core_external\external_api; use core_external\external_function_parameters; @@ -34,8 +37,6 @@ use quiz_archiver\ArchiveJob; use quiz_archiver\BackupManager; -defined('MOODLE_INTERNAL') || die(); - /** * API endpoint to get the status of a Moodle backup */ @@ -47,8 +48,16 @@ class get_backup_status extends external_api { */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters([ - 'jobid' => new external_value(PARAM_TEXT, 'UUID of the job this artifact is associated with', VALUE_REQUIRED), - 'backupid' => new external_value(PARAM_TEXT, 'ID of the backup controller', VALUE_REQUIRED), + 'jobid' => new external_value( + PARAM_TEXT, + 'UUID of the job this artifact is associated with', + VALUE_REQUIRED + ), + 'backupid' => new external_value( + PARAM_TEXT, + 'ID of the backup controller', + VALUE_REQUIRED + ), ]); } @@ -58,15 +67,19 @@ public static function execute_parameters(): external_function_parameters { */ public static function execute_returns(): external_single_structure { return new external_single_structure([ - 'status' => new external_value(PARAM_TEXT, 'Status of the requested backup', VALUE_REQUIRED), + 'status' => new external_value( + PARAM_TEXT, + 'Status of the requested backup', + VALUE_REQUIRED + ), ]); } /** * Execute the webservice function * - * @param string $jobid_raw - * @param string $backupid_raw + * @param string $jobidraw + * @param string $backupidraw * @return array * @throws \coding_exception * @throws \dml_exception @@ -74,32 +87,36 @@ public static function execute_returns(): external_single_structure { * @throws \required_capability_exception */ public static function execute( - string $jobid_raw, - string $backupid_raw + string $jobidraw, + string $backupidraw ): array { - // Validate request + // Validate request. $params = self::validate_parameters(self::execute_parameters(), [ - 'jobid' => $jobid_raw, - 'backupid' => $backupid_raw, + 'jobid' => $jobidraw, + 'backupid' => $backupidraw, ]); - // Validate that the jobid exists + // Validate that the jobid exists. try { $job = ArchiveJob::get_by_jobid($params['jobid']); } catch (\dml_exception $e) { return ['status' => 'E_JOB_NOT_FOUND']; } - // Check access rights + // Check access rights. if (!$job->has_read_access(optional_param('wstoken', null, PARAM_TEXT))) { return ['status' => 'E_ACCESS_DENIED']; } - // Check capabilities - $context = \context_module::instance($job->get_cm_id()); + // Check capabilities. + $context = \context_module::instance($job->get_cmid()); require_capability('mod/quiz_archiver:use_webservice', $context); - // Get backup + // The following code is tested covered by more specific tests. + // @codingStandardsIgnoreLine + // @codeCoverageIgnoreStart + + // Get backup. try { $bm = new BackupManager($params['backupid']); @@ -118,8 +135,11 @@ public static function execute( return ['status' => 'E_BACKUP_NOT_FOUND']; } - // Report success + // Report success. return ['status' => 'SUCCESS']; + + // @codingStandardsIgnoreLine + // @codeCoverageIgnoreEnd } } diff --git a/classes/external/process_uploaded_artifact.php b/classes/external/process_uploaded_artifact.php index bc729aa..a4d4daf 100644 --- a/classes/external/process_uploaded_artifact.php +++ b/classes/external/process_uploaded_artifact.php @@ -24,8 +24,11 @@ namespace quiz_archiver\external; -// TODO: Remove after deprecation of Moodle 4.1 (LTS) on 08-12-2025 -require_once($CFG->dirroot.'/mod/quiz/report/archiver/patch_401_class_renames.php'); +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore + + +// TODO (MDL-0): Remove after deprecation of Moodle 4.1 (LTS) on 08-12-2025. +require_once($CFG->dirroot.'/mod/quiz/report/archiver/patch_401_class_renames.php'); // @codeCoverageIgnore use core_external\external_api; use core_external\external_function_parameters; @@ -34,8 +37,6 @@ use quiz_archiver\ArchiveJob; use quiz_archiver\FileManager; -defined('MOODLE_INTERNAL') || die(); - /** * API endpoint to process an artifact that was uploaded by the quiz archiver worker service */ @@ -47,15 +48,51 @@ class process_uploaded_artifact extends external_api { */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters([ - 'jobid' => new external_value(PARAM_TEXT, 'UUID of the job this artifact is associated with', VALUE_REQUIRED), - 'artifact_component' => new external_value(PARAM_TEXT, 'File API component', VALUE_REQUIRED), - 'artifact_contextid' => new external_value(PARAM_INT, 'File API contextid', VALUE_REQUIRED), - 'artifact_userid' => new external_value(PARAM_INT, 'File API userid', VALUE_REQUIRED), - 'artifact_filearea' => new external_value(PARAM_TEXT, 'File API filearea', VALUE_REQUIRED), - 'artifact_filename' => new external_value(PARAM_TEXT, 'File API filename', VALUE_REQUIRED), - 'artifact_filepath' => new external_value(PARAM_TEXT, 'File API filepath', VALUE_REQUIRED), - 'artifact_itemid' => new external_value(PARAM_INT, 'File API itemid', VALUE_REQUIRED), - 'artifact_sha256sum' => new external_value(PARAM_TEXT, 'SHA256 checksum of the file', VALUE_REQUIRED), + 'jobid' => new external_value( + PARAM_TEXT, + 'UUID of the job this artifact is associated with', + VALUE_REQUIRED + ), + 'artifact_component' => new external_value( + PARAM_TEXT, + 'File API component', + VALUE_REQUIRED + ), + 'artifact_contextid' => new external_value( + PARAM_INT, + 'File API contextid', + VALUE_REQUIRED + ), + 'artifact_userid' => new external_value( + PARAM_INT, + 'File API userid', + VALUE_REQUIRED + ), + 'artifact_filearea' => new external_value( + PARAM_TEXT, + 'File API filearea', + VALUE_REQUIRED + ), + 'artifact_filename' => new external_value( + PARAM_TEXT, + 'File API filename', + VALUE_REQUIRED + ), + 'artifact_filepath' => new external_value( + PARAM_TEXT, + 'File API filepath', + VALUE_REQUIRED + ), + 'artifact_itemid' => new external_value( + PARAM_INT, + 'File API itemid', + VALUE_REQUIRED + ), + 'artifact_sha256sum' => new external_value( + PARAM_TEXT, + 'SHA256 checksum of the file', + VALUE_REQUIRED + ), ]); } @@ -65,22 +102,25 @@ public static function execute_parameters(): external_function_parameters { */ public static function execute_returns(): external_single_structure { return new external_single_structure([ - 'status' => new external_value(PARAM_TEXT, 'Status of the executed wsfunction'), + 'status' => new external_value( + PARAM_TEXT, + 'Status of the executed wsfunction'), + ]); } /** * Execute the webservice function * - * @param string $jobid_raw - * @param string $artifact_component_raw - * @param int $artifact_contextid_raw - * @param int $artifact_userid_raw - * @param string $artifact_filearea_raw - * @param string $artifact_filename_raw - * @param string $artifact_filepath_raw - * @param int $artifact_itemid_raw - * @param string $artifact_sha256sum_raw + * @param string $jobidraw + * @param string $artifactcomponentraw + * @param int $artifactcontextidraw + * @param int $artifactuseridraw + * @param string $artifactfilearearaw + * @param string $artifactfilenameraw + * @param string $artifactfilepathraw + * @param int $artifactitemidraw + * @param string $artifactsha256sumraw * @return array * @throws \coding_exception * @throws \dml_exception @@ -88,30 +128,30 @@ public static function execute_returns(): external_single_structure { * @throws \required_capability_exception */ public static function execute( - string $jobid_raw, - string $artifact_component_raw, - int $artifact_contextid_raw, - int $artifact_userid_raw, - string $artifact_filearea_raw, - string $artifact_filename_raw, - string $artifact_filepath_raw, - int $artifact_itemid_raw, - string $artifact_sha256sum_raw + string $jobidraw, + string $artifactcomponentraw, + int $artifactcontextidraw, + int $artifactuseridraw, + string $artifactfilearearaw, + string $artifactfilenameraw, + string $artifactfilepathraw, + int $artifactitemidraw, + string $artifactsha256sumraw ): array { - // Validate request + // Validate request. $params = self::validate_parameters(self::execute_parameters(), [ - 'jobid' => $jobid_raw, - 'artifact_component' => $artifact_component_raw, - 'artifact_contextid' => $artifact_contextid_raw, - 'artifact_userid' => $artifact_userid_raw, - 'artifact_filearea' => $artifact_filearea_raw, - 'artifact_filename' => $artifact_filename_raw, - 'artifact_filepath' => $artifact_filepath_raw, - 'artifact_itemid' => $artifact_itemid_raw, - 'artifact_sha256sum' => $artifact_sha256sum_raw, + 'jobid' => $jobidraw, + 'artifact_component' => $artifactcomponentraw, + 'artifact_contextid' => $artifactcontextidraw, + 'artifact_userid' => $artifactuseridraw, + 'artifact_filearea' => $artifactfilearearaw, + 'artifact_filename' => $artifactfilenameraw, + 'artifact_filepath' => $artifactfilepathraw, + 'artifact_itemid' => $artifactitemidraw, + 'artifact_sha256sum' => $artifactsha256sumraw, ]); - // Validate that the jobid exists and no artifact was uploaded previously + // Validate that the jobid exists and no artifact was uploaded previously. try { $job = ArchiveJob::get_by_jobid($params['jobid']); if ($job->is_complete()) { @@ -125,19 +165,19 @@ public static function execute( ]; } - // Check access rights + // Check access rights. if (!$job->has_write_access(optional_param('wstoken', null, PARAM_TEXT))) { return [ 'status' => 'E_ACCESS_DENIED', ]; } - // Check capabilities - $context = \context_module::instance($job->get_cm_id()); + // Check capabilities. + $context = \context_module::instance($job->get_cmid()); require_capability('mod/quiz_archiver:use_webservice', $context); - // Validate uploaded file - // Note: We use SHA256 instead of Moodle sha1, since SHA1 is prone to + // Validate uploaded file. + // Note: We use SHA256 instead of Moodle sha1, since SHA1 is prone to. // hash collisions! $draftfile = FileManager::get_draft_file( $params['artifact_contextid'], @@ -160,10 +200,14 @@ public static function execute( ]; } - // Store uploaded file - $fm = new FileManager($job->get_course_id(), $job->get_cm_id(), $job->get_quiz_id()); + // The following code is tested covered by more specific tests. + // @codingStandardsIgnoreLine + // @codeCoverageIgnoreStart + + // Store uploaded file. + $fm = new FileManager($job->get_courseid(), $job->get_cmid(), $job->get_quizid()); try { - $artifact = $fm->store_uploaded_artifact($draftfile); + $artifact = $fm->store_uploaded_artifact($draftfile, $job->get_id()); $job->link_artifact($artifact->get_id(), $params['artifact_sha256sum']); } catch (\Exception $e) { $job->set_status(ArchiveJob::STATUS_FAILED); @@ -172,24 +216,31 @@ public static function execute( ]; } - // Timestamp artifact file using TSP - if ($job->TSPManager()->wants_tsp_timestamp()) { + // Timestamp artifact file using TSP. + if ($job->tspmanager()->wants_tsp_timestamp()) { try { - $job->TSPManager()->timestamp(); + $job->tspmanager()->timestamp(); + // @codingStandardsIgnoreStart } catch (\Exception $e) { // TODO: Fail silently for now ... - // $job->set_status(ArchiveJob::STATUS_FAILED); - // return [ - // 'status' => 'E_TSP_TIMESTAMP_FAILED' - // ]; + /* + $job->set_status(ArchiveJob::STATUS_FAILED); + return [ + 'status' => 'E_TSP_TIMESTAMP_FAILED' + ]; + */ } + // @codingStandardsIgnoreEnd } - // Report success + // Report success. $job->set_status(ArchiveJob::STATUS_FINISHED); return [ 'status' => 'OK', ]; + + // @codingStandardsIgnoreLine + // @codeCoverageIgnoreEnd } } diff --git a/classes/external/update_job_status.php b/classes/external/update_job_status.php index fe522ad..4b97625 100644 --- a/classes/external/update_job_status.php +++ b/classes/external/update_job_status.php @@ -24,8 +24,11 @@ namespace quiz_archiver\external; -// TODO: Remove after deprecation of Moodle 4.1 (LTS) on 08-12-2025 -require_once($CFG->dirroot.'/mod/quiz/report/archiver/patch_401_class_renames.php'); +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore + + +// TODO (MDL-0): Remove after deprecation of Moodle 4.1 (LTS) on 08-12-2025. +require_once($CFG->dirroot.'/mod/quiz/report/archiver/patch_401_class_renames.php'); // @codeCoverageIgnore use core_external\external_api; use core_external\external_function_parameters; @@ -33,8 +36,6 @@ use core_external\external_value; use quiz_archiver\ArchiveJob; -defined('MOODLE_INTERNAL') || die(); - /** * API endpoint to update the status of a quiz archiver job */ @@ -46,8 +47,21 @@ class update_job_status extends external_api { */ public static function execute_parameters(): external_function_parameters { return new external_function_parameters([ - 'jobid' => new external_value(PARAM_TEXT, 'UUID of the job this artifact is associated with', VALUE_REQUIRED), - 'status' => new external_value(PARAM_TEXT, 'New status to set for job with UUID of jobid', VALUE_REQUIRED), + 'jobid' => new external_value( + PARAM_TEXT, + 'UUID of the job this artifact is associated with', + VALUE_REQUIRED + ), + 'status' => new external_value( + PARAM_TEXT, + 'New status to set for job with UUID of jobid', + VALUE_REQUIRED + ), + 'statusextras' => new external_value( + PARAM_RAW, + 'JSON containing additional information for the new job status', + VALUE_DEFAULT + ), ]); } @@ -57,35 +71,41 @@ public static function execute_parameters(): external_function_parameters { */ public static function execute_returns(): external_single_structure { return new external_single_structure([ - 'status' => new external_value(PARAM_TEXT, 'Status of the executed wsfunction'), + 'status' => new external_value( + PARAM_TEXT, + 'Status of the executed wsfunction' + ), ]); } /** * Execute the webservice function * - * @param string $jobid_raw - * @param string $status_raw + * @param string $jobidraw + * @param string $statusraw + * @param string|null $statusextrasraw * @return array + * @throws \coding_exception * @throws \invalid_parameter_exception * @throws \required_capability_exception - * @throws \coding_exception */ public static function execute( - string $jobid_raw, - string $status_raw + string $jobidraw, + string $statusraw, + ?string $statusextrasraw = null ): array { - // Validate request + // Validate request. $params = self::validate_parameters(self::execute_parameters(), [ - 'jobid' => $jobid_raw, - 'status' => $status_raw, + 'jobid' => $jobidraw, + 'status' => $statusraw, + 'statusextras' => $statusextrasraw, ]); try { $job = ArchiveJob::get_by_jobid($params['jobid']); - // Check capabilities - $context = \context_module::instance($job->get_cm_id()); + // Check capabilities. + $context = \context_module::instance($job->get_cmid()); require_capability('mod/quiz_archiver:use_webservice', $context); if ($job->is_complete()) { @@ -100,14 +120,28 @@ public static function execute( ]; } - $job->set_status($params['status']); + // Prepare statusextras. + $statusextras = null; + if ($params['statusextras']) { + $statusextras = json_decode($params['statusextras'], true, 16, JSON_THROW_ON_ERROR); + } + + // Update job status. + $job->set_status( + $params['status'], + $statusextras + ); } catch (\dml_exception $e) { return [ 'status' => 'E_UPDATE_FAILED', ]; + } catch (\JsonException $e) { + return [ + 'status' => 'E_INVALID_STATUSEXTRAS_JSON', + ]; } - // Report success + // Report success. return [ 'status' => 'OK', ]; diff --git a/classes/form/archive_quiz_form.php b/classes/form/archive_quiz_form.php index 13c6ba4..cbfb07f 100644 --- a/classes/form/archive_quiz_form.php +++ b/classes/form/archive_quiz_form.php @@ -28,9 +28,10 @@ use quiz_archiver\local\util; use quiz_archiver\Report; -defined('MOODLE_INTERNAL') || die(); +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore -require_once($CFG->dirroot.'/lib/formslib.php'); + +require_once($CFG->dirroot.'/lib/formslib.php'); // @codeCoverageIgnore /** @@ -38,20 +39,20 @@ */ class archive_quiz_form extends \moodleform { - /** @var string Name of the quiz to be exportet */ - protected string $quiz_name; + /** @var string Name of the quiz to be exported */ + protected string $quizname; /** @var int Number of attempts to be exported */ - protected int $num_attempts; + protected int $numattempts; /** * Creates a new archive_quiz_form instance * - * @param string $quiz_name Name of the quiz to be exported - * @param int $num_attempts Number of attempts to be exported + * @param string $quizname Name of the quiz to be exported + * @param int $numattempts Number of attempts to be exported */ - public function __construct(string $quiz_name, int $num_attempts) { - $this->quiz_name = $quiz_name; - $this->num_attempts = $num_attempts; + public function __construct(string $quizname, int $numattempts) { + $this->quizname = $quizname; + $this->numattempts = $numattempts; parent::__construct(); } @@ -61,31 +62,33 @@ public function __construct(string $quiz_name, int $num_attempts) { * @throws \coding_exception */ public function definition() { + global $CFG; + $config = get_config('quiz_archiver'); $mform = $this->_form; - // Title and description + // Title and description. $mform->addElement('html', '

'.get_string('create_quiz_archive', 'quiz_archiver').'

'); $mform->addElement('html', '

'.get_string('archive_quiz_form_desc', 'quiz_archiver').'

'); - // Internal information of mod_quiz + // Internal information of mod_quiz. $mform->addElement('hidden', 'id', $this->optional_param('id', null, PARAM_INT)); $mform->setType('id', PARAM_INT); $mform->addElement('hidden', 'mode', 'archiver'); $mform->setType('mode', PARAM_TEXT); - // Options + // Options. $mform->addElement('header', 'header_settings', get_string('settings')); - // Options: Test - $mform->addElement('static', 'quiz_name', get_string('modulename', 'mod_quiz'), $this->quiz_name); + // Options: Test. + $mform->addElement('static', 'quiz_name', get_string('modulename', 'mod_quiz'), $this->quizname); - // Options: Attempts + // Options: Attempts. $mform->addElement( 'advcheckbox', 'export_attempts', get_string('attempts', 'mod_quiz'), - get_string('export_attempts_num', 'quiz_archiver', $this->num_attempts), + get_string('export_attempts_num', 'quiz_archiver', $this->numattempts), ['disabled' => 'disabled'], ['1', '1'] ); @@ -109,7 +112,7 @@ public function definition() { } } - // Options: Backups + // Options: Backups. $mform->addElement( 'advcheckbox', 'export_quiz_backup', @@ -130,10 +133,11 @@ public function definition() { $mform->addHelpButton('export_course_backup', 'export_course_backup', 'quiz_archiver'); $mform->setDefault('export_course_backup', $config->job_preset_export_course_backup); - // Advanced options + // Advanced options. $mform->addElement('header', 'header_advanced_settings', get_string('advancedsettings')); $mform->setExpanded('header_advanced_settings', false); + // Advanced options: Paper format. $mform->addElement( 'select', 'export_attempts_paper_format', @@ -144,52 +148,203 @@ public function definition() { $mform->addHelpButton('export_attempts_paper_format', 'export_attempts_paper_format', 'quiz_archiver'); $mform->setDefault('export_attempts_paper_format', $config->job_preset_export_attempts_paper_format); - $mform->addElement( - 'advcheckbox', - 'export_attempts_keep_html_files', - get_string('export_attempts_keep_html_files', 'quiz_archiver'), - get_string('export_attempts_keep_html_files_desc', 'quiz_archiver'), - $config->job_preset_export_attempts_keep_html_files_locked ? 'disabled' : null - ); - $mform->addHelpButton('export_attempts_keep_html_files', 'export_attempts_keep_html_files', 'quiz_archiver'); - $mform->setDefault('export_attempts_keep_html_files', $config->job_preset_export_attempts_keep_html_files); - + // Advanced options: Archive filename pattern. $mform->addElement( 'text', 'archive_filename_pattern', get_string('archive_filename_pattern', 'quiz_archiver'), $config->job_preset_archive_filename_pattern_locked ? 'disabled' : null ); - $mform->addHelpButton('archive_filename_pattern', 'archive_filename_pattern', 'quiz_archiver', '', false, [ - 'variables' => array_reduce( - ArchiveJob::ARCHIVE_FILENAME_PATTERN_VARIABLES, - fn ($res, $varname) => $res . "
  • \${".$varname."}: ".get_string('archive_filename_pattern_variable_'.$varname, 'quiz_archiver')."
  • ", - "" - ), - 'forbiddenchars' => implode('', ArchiveJob::FILENAME_FORBIDDEN_CHARACTERS), - ]); + if ($CFG->branch > 402) { + $mform->addHelpButton( + 'archive_filename_pattern', + 'archive_filename_pattern', + 'quiz_archiver', + '', + false, + [ + 'variables' => array_reduce( + ArchiveJob::ARCHIVE_FILENAME_PATTERN_VARIABLES, + fn($res, $varname) => $res."
  • ". + "\${".$varname."}: ". + get_string('archive_filename_pattern_variable_'.$varname, 'quiz_archiver'). + "
  • ", + "" + ), + 'forbiddenchars' => implode('', ArchiveJob::FILENAME_FORBIDDEN_CHARACTERS), + ] + ); + } else { + // TODO (MDL-0): Remove after deprecation of Moodle 4.1 (LTS) on 08-12-2025. + $mform->addHelpButton('archive_filename_pattern', 'archive_filename_pattern_moodle42', 'quiz_archiver'); + } $mform->setType('archive_filename_pattern', PARAM_TEXT); $mform->setDefault('archive_filename_pattern', $config->job_preset_archive_filename_pattern); $mform->addRule('archive_filename_pattern', null, 'maxlength', 255, 'client'); + // Advanced options: Attempts filename pattern. $mform->addElement( 'text', 'export_attempts_filename_pattern', get_string('export_attempts_filename_pattern', 'quiz_archiver'), $config->job_preset_export_attempts_filename_pattern_locked ? 'disabled' : null ); - $mform->addHelpButton('export_attempts_filename_pattern', 'export_attempts_filename_pattern', 'quiz_archiver', '', false, [ - 'variables' => array_reduce( - ArchiveJob::ATTEMPT_FILENAME_PATTERN_VARIABLES, - fn ($res, $varname) => $res . "
  • \${".$varname."}: ".get_string('export_attempts_filename_pattern_variable_'.$varname, 'quiz_archiver')."
  • ", - "" - ), - 'forbiddenchars' => implode('', ArchiveJob::FILENAME_FORBIDDEN_CHARACTERS), - ]); + if ($CFG->branch > 402) { + $mform->addHelpButton( + 'export_attempts_filename_pattern', + 'export_attempts_filename_pattern', + 'quiz_archiver', + '', + false, + [ + 'variables' => array_reduce( + ArchiveJob::ATTEMPT_FILENAME_PATTERN_VARIABLES, + fn($res, $varname) => $res."
  • ". + "\${".$varname."}: ". + get_string('export_attempts_filename_pattern_variable_'.$varname, 'quiz_archiver'). + "
  • ", + "" + ), + 'forbiddenchars' => implode('', ArchiveJob::FILENAME_FORBIDDEN_CHARACTERS), + ] + ); + } else { + // TODO (MDL-0): Remove after deprecation of Moodle 4.1 (LTS) on 08-12-2025. + $mform->addHelpButton('export_attempts_filename_pattern', 'export_attempts_filename_pattern_moodle42', 'quiz_archiver'); + } $mform->setType('export_attempts_filename_pattern', PARAM_TEXT); $mform->setDefault('export_attempts_filename_pattern', $config->job_preset_export_attempts_filename_pattern); $mform->addRule('export_attempts_filename_pattern', null, 'maxlength', 255, 'client'); + // Advanced options: Image optimization. + $mform->addElement( + 'advcheckbox', + 'export_attempts_image_optimize', + get_string('export_attempts_image_optimize', 'quiz_archiver'), + get_string('enable'), + $config->job_preset_export_attempts_image_optimize_locked ? 'disabled' : null, + ['0', '1'] + ); + $mform->addHelpButton('export_attempts_image_optimize', 'export_attempts_image_optimize', 'quiz_archiver'); + $mform->setDefault('export_attempts_image_optimize', $config->job_preset_export_attempts_image_optimize); + + // Image max width/height fields. + $mformgroup = []; + $mformgroupfieldseperator = 'x'; + if ($config->job_preset_export_attempts_image_optimize_width_locked) { + $mformgroup[] = $mform->createElement( + 'static', + 'export_attempts_image_optimize_width_static', + '', + $config->job_preset_export_attempts_image_optimize_width + ); + $mform->addElement( + 'hidden', + 'export_attempts_image_optimize_width', + $config->job_preset_export_attempts_image_optimize_width + ); + } else { + $mformgroup[] = $mform->createElement( + 'text', + 'export_attempts_image_optimize_width', + get_string('export_attempts_image_optimize_width', 'quiz_archiver'), + ['size' => 4] + ); + $mform->setDefault('export_attempts_image_optimize_width', $config->job_preset_export_attempts_image_optimize_width); + } + $mform->setType('export_attempts_image_optimize_width', PARAM_INT); + + if ($config->job_preset_export_attempts_image_optimize_height_locked) { + $mformgroup[] = $mform->createElement( + 'static', + 'export_attempts_image_optimize_height_static', + '', + $config->job_preset_export_attempts_image_optimize_height + ); + $mform->addElement( + 'hidden', + 'export_attempts_image_optimize_height', + $config->job_preset_export_attempts_image_optimize_height + ); + } else { + $mformgroup[] = $mform->createElement( + 'text', + 'export_attempts_image_optimize_height', + get_string('export_attempts_image_optimize_height', 'quiz_archiver'), + ['size' => 4] + ); + $mform->setDefault('export_attempts_image_optimize_height', $config->job_preset_export_attempts_image_optimize_height); + $mformgroupfieldseperator .= ' '; + } + $mform->setType('export_attempts_image_optimize_height', PARAM_INT); + + $mformgroup[] = $mform->createElement('static', 'export_attempts_image_optimize_px', '', 'px'); + + $mform->addGroup( + $mformgroup, + 'export_attempts_image_optimize_group', + get_string('export_attempts_image_optimize_group', 'quiz_archiver'), + [$mformgroupfieldseperator, ''], + false + ); + $mform->addHelpButton('export_attempts_image_optimize_group', 'export_attempts_image_optimize_group', 'quiz_archiver'); + $mform->hideIf('export_attempts_image_optimize_group', 'export_attempts_image_optimize', 'notchecked'); + + // Image quality field. + $mformgroup = []; + if ($config->job_preset_export_attempts_image_optimize_quality_locked) { + $mformgroup[] = $mform->createElement( + 'static', + 'export_attempts_image_optimize_quality_static', + '', + $config->job_preset_export_attempts_image_optimize_quality + ); + $mform->addElement( + 'hidden', + 'export_attempts_image_optimize_quality', + $config->job_preset_export_attempts_image_optimize_quality + ); + } else { + $mformgroup[] = $mform->createElement( + 'text', + 'export_attempts_image_optimize_quality', + get_string('export_attempts_image_optimize_quality', 'quiz_archiver'), + ['size' => 2] + ); + $mform->setDefault( + 'export_attempts_image_optimize_quality', + $config->job_preset_export_attempts_image_optimize_quality + ); + } + $mform->setType('export_attempts_image_optimize_quality', PARAM_INT); + + $mformgroup[] = $mform->createElement('static', 'export_attempts_image_optimize_quality_percent', '', '%'); + $mform->addGroup( + $mformgroup, + 'export_attempts_image_optimize_quality_group', + get_string('export_attempts_image_optimize_quality', 'quiz_archiver'), + '', + false + ); + $mform->addHelpButton( + 'export_attempts_image_optimize_quality_group', + 'export_attempts_image_optimize_quality', + 'quiz_archiver' + ); + $mform->hideIf('export_attempts_image_optimize_quality_group', 'export_attempts_image_optimize', 'notchecked'); + + // Advanced options: Keep HTML files. + $mform->addElement( + 'advcheckbox', + 'export_attempts_keep_html_files', + get_string('export_attempts_keep_html_files', 'quiz_archiver'), + get_string('export_attempts_keep_html_files_desc', 'quiz_archiver'), + $config->job_preset_export_attempts_keep_html_files_locked ? 'disabled' : null + ); + $mform->addHelpButton('export_attempts_keep_html_files', 'export_attempts_keep_html_files', 'quiz_archiver'); + $mform->setDefault('export_attempts_keep_html_files', $config->job_preset_export_attempts_keep_html_files); + + // Advanced options: Autodelete. $mform->addElement( 'advcheckbox', 'archive_autodelete', @@ -201,29 +356,38 @@ public function definition() { $mform->addHelpButton('archive_autodelete', 'archive_autodelete', 'quiz_archiver'); $mform->setDefault('archive_autodelete', $config->job_preset_archive_autodelete); + $mformgroup = []; // This is wrapped in a form group to make hideIf() work with static elements. if ($config->job_preset_archive_retention_time_locked) { $durationwithunit = util::duration_to_unit($config->job_preset_archive_retention_time); - $mform->addElement( + $mformgroup[] = $mform->createElement( 'static', 'archive_retention_time_static', - get_string('archive_retention_time', 'quiz_archiver'), + '', $durationwithunit[0].' '.$durationwithunit[1] ); $mform->addElement('hidden', 'archive_retention_time', $config->job_preset_archive_retention_time); } else { - $mform->addElement( + $mformgroup[] = $mform->createElement( 'duration', 'archive_retention_time', - get_string('archive_retention_time', 'quiz_archiver'), + '', ['optional' => false, 'defaultunit' => DAYSECS], ); $mform->setDefault('archive_retention_time', $config->job_preset_archive_retention_time); } $mform->setType('archive_retention_time', PARAM_INT); - $mform->addHelpButton('archive_retention_time', 'archive_retention_time', 'quiz_archiver'); - $mform->hideIf('archive_retention_time', 'archive_autodelete', 'notchecked'); - // Submit + $mform->addGroup( + $mformgroup, + 'archive_retention_time_group', + get_string('archive_retention_time', 'quiz_archiver'), + '', + false + ); + $mform->addHelpButton('archive_retention_time_group', 'archive_retention_time', 'quiz_archiver'); + $mform->hideIf('archive_retention_time_group', 'archive_autodelete', 'notchecked'); + + // Submit. $mform->closeHeaderBefore('submitbutton'); $mform->addElement('submit', 'submitbutton', get_string('archive_quiz', 'quiz_archiver')); } @@ -236,10 +400,10 @@ public function definition() { * @return array Associative array with error messages for invalid fields * @throws \coding_exception */ - function validation($data, $files) { + public function validation($data, $files) { $errors = parent::validation($data, $files); - // Validate filename pattern + // Validate filename pattern. if (!ArchiveJob::is_valid_archive_filename_pattern($data['archive_filename_pattern'])) { $errors['archive_filename_pattern'] = get_string('error_invalid_archive_filename_pattern', 'quiz_archiver'); } @@ -257,11 +421,11 @@ function validation($data, $files) { * @return \stdClass Cleared, submitted form data * @throws \dml_exception */ - public function get_data():\stdClass { + public function get_data(): \stdClass { $data = parent::get_data(); $config = get_config('quiz_archiver'); - // Force locked fields to their preset values + // Force locked fields to their preset values. foreach ($config as $key => $value) { if (strpos($key, 'job_preset_') === 0 && strrpos($key, '_locked') === strlen($key) - 7) { if ($value) { diff --git a/classes/form/artifact_delete_form.php b/classes/form/artifact_delete_form.php index 35e864e..e80960e 100644 --- a/classes/form/artifact_delete_form.php +++ b/classes/form/artifact_delete_form.php @@ -26,9 +26,10 @@ use quiz_archiver\ArchiveJob; -defined('MOODLE_INTERNAL') || die(); +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore -require_once($CFG->dirroot.'/lib/formslib.php'); + +require_once($CFG->dirroot.'/lib/formslib.php'); // @codeCoverageIgnore /** @@ -43,26 +44,28 @@ class artifact_delete_form extends \moodleform { * @throws \coding_exception */ public function definition() { - $mform = $this->_form; + global $OUTPUT; + $mform = $this->_form; - // Find job + // Find job. $job = ArchiveJob::get_by_jobid($this->optional_param('jobid', null, PARAM_TEXT)); $artifactfile = $job->get_artifact(); - // Generic warning message - $warn_head = get_string('delete_artifact', 'quiz_archiver'); + // Generic warning message. + $warnhead = get_string('delete_artifact', 'quiz_archiver'); if ($artifactfile) { - $warn_msg = get_string('delete_artifact_warning', 'quiz_archiver'); - $warn_details = get_string('jobid', 'quiz_archiver').': '.$job->get_jobid(); - $warn_details .= '
    '; - $warn_details .= get_string('quiz_archive', 'quiz_archiver').': ' .$artifactfile->get_filename().' ('.display_size($artifactfile->get_filesize()).')'; + $warnmsg = get_string('delete_artifact_warning', 'quiz_archiver'); + $warndetails = get_string('jobid', 'quiz_archiver').': '.$job->get_jobid(); + $warndetails .= '
    '; + $warndetails .= get_string('quiz_archive', 'quiz_archiver').': '.$artifactfile->get_filename(). + ' ('.display_size($artifactfile->get_filesize()).')'; - // Warn additionally if job is scheduled for automatic deletion + // Warn additionally if job is scheduled for automatic deletion. if ($job->is_autodelete_enabled()) { if ($job->get_status() === ArchiveJob::STATUS_FINISHED) { - $warn_msg .= '

    '; - $warn_msg .= get_string( + $warnmsg .= '

    '; + $warnmsg .= get_string( 'delete_job_warning_retention', 'quiz_archiver', userdate($job->get_retentiontime(), get_string('strftimedatetime', 'langconfig')) @@ -70,34 +73,31 @@ public function definition() { } } } else { - $warn_msg = get_string('error').': '.get_string('quiz_archive_not_found', 'quiz_archiver', $job->get_jobid()); - $warn_details = get_string('jobid', 'quiz_archiver').': '.$job->get_jobid(); + $warnmsg = get_string('error').': '.get_string('quiz_archive_not_found', 'quiz_archiver', $job->get_jobid()); + $warndetails = get_string('jobid', 'quiz_archiver').': '.$job->get_jobid(); } - // Print warning element - $mform->addElement('html', << -

    $warn_head

    - $warn_msg -
    - $warn_details - - EOD); - - // Preserve internal information of mod_quiz + // Print warning element. + $mform->addElement('html', $OUTPUT->notification( + "

    $warnhead

    $warnmsg
    $warndetails", + \core\output\notification::NOTIFY_WARNING, + false, + )); + + // Preserve internal information of mod_quiz. $mform->addElement('hidden', 'id', $this->optional_param('id', null, PARAM_INT)); $mform->setType('id', PARAM_INT); $mform->addElement('hidden', 'mode', 'archiver'); $mform->setType('mode', PARAM_TEXT); if ($artifactfile) { - // Options + // Options. $mform->addElement('hidden', 'action', 'delete_artifact'); $mform->setType('action', PARAM_TEXT); $mform->addElement('hidden', 'jobid', $job->get_jobid()); $mform->setType('jobid', PARAM_TEXT); - // Action buttons + // Action buttons. $this->add_action_buttons(true, get_string('delete', 'moodle')); } else { $this->add_action_buttons(false, get_string('back')); diff --git a/classes/form/autoinstall_form.php b/classes/form/autoinstall_form.php new file mode 100644 index 0000000..6f0d16a --- /dev/null +++ b/classes/form/autoinstall_form.php @@ -0,0 +1,80 @@ +. + +/** + * Defines the editing form for artifacts + * + * @package quiz_archiver + * @copyright 2024 Niels Gandraß + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace quiz_archiver\form; + +use quiz_archiver\local\autoinstall; + +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore + + +require_once($CFG->dirroot.'/lib/formslib.php'); // @codeCoverageIgnore +require_once($CFG->dirroot.'/mod/quiz/report/archiver/classes/local/autoinstall.php'); // @codeCoverageIgnore + + +/** + * Form to trigger automatic installation of the quiz archiver plugin + */ +class autoinstall_form extends \moodleform { + + /** + * Form definiton. + * + * @throws \dml_exception + * @throws \coding_exception + * @throws \moodle_exception + */ + public function definition() { + $mform = $this->_form; + $mform->addElement('header', 'header', get_string('settings', 'plugin')); + + // Add configuration options. + $mform->addElement('text', 'workerurl', get_string('setting_worker_url', 'quiz_archiver'), ['size' => 50]); + $mform->addElement('static', 'workerurl_help', '', get_string('setting_worker_url_desc', 'quiz_archiver')); + $mform->setType('workerurl', PARAM_TEXT); + $mform->addRule('workerurl', null, 'required', null, 'client'); + + $mform->addElement('text', 'wsname', get_string('autoinstall_wsname', 'quiz_archiver'), ['size' => 50]); + $mform->addElement('static', 'wsname_help', '', get_string('autoinstall_wsname_help', 'quiz_archiver')); + $mform->setDefault('wsname', autoinstall::DEFAULT_WSNAME); + $mform->setType('wsname', PARAM_TEXT); + $mform->addRule('wsname', null, 'required', null, 'client'); + + $mform->addElement('text', 'rolename', get_string('autoinstall_rolename', 'quiz_archiver'), ['size' => 50]); + $mform->addElement('static', 'rolename_help', '', get_string('autoinstall_rolename_help', 'quiz_archiver')); + $mform->setDefault('rolename', autoinstall::DEFAULT_ROLESHORTNAME); + $mform->setType('rolename', PARAM_TEXT); + $mform->addRule('rolename', null, 'required', null, 'client'); + + $mform->addElement('text', 'username', get_string('autoinstall_username', 'quiz_archiver'), ['size' => 50]); + $mform->addElement('static', 'username_help', '', get_string('autoinstall_username_help', 'quiz_archiver')); + $mform->setDefault('username', autoinstall::DEFAULT_USERNAME); + $mform->setType('username', PARAM_TEXT); + $mform->addRule('username', null, 'required', null, 'client'); + + // Action buttons. + $this->add_action_buttons(true, get_string('confirm', 'moodle')); + } + +} diff --git a/classes/form/job_delete_form.php b/classes/form/job_delete_form.php index 5ca71fd..290fbbf 100644 --- a/classes/form/job_delete_form.php +++ b/classes/form/job_delete_form.php @@ -26,9 +26,10 @@ use quiz_archiver\ArchiveJob; -defined('MOODLE_INTERNAL') || die(); +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore -require_once($CFG->dirroot.'/lib/formslib.php'); + +require_once($CFG->dirroot.'/lib/formslib.php'); // @codeCoverageIgnore /** @@ -43,28 +44,30 @@ class job_delete_form extends \moodleform { * @throws \coding_exception */ public function definition() { + global $OUTPUT; $mform = $this->_form; - // Find job + // Find job. $job = ArchiveJob::get_by_jobid($this->optional_param('jobid', null, PARAM_TEXT)); $artifactfile = $job->get_artifact(); - // Generic warning message - $warn_head = get_string('delete_job', 'quiz_archiver'); - $warn_msg = get_string('delete_job_warning', 'quiz_archiver'); - $warn_details = get_string('jobid', 'quiz_archiver').': '.$job->get_jobid(); + // Generic warning message. + $warnhead = get_string('delete_job', 'quiz_archiver'); + $warnmsg = get_string('delete_job_warning', 'quiz_archiver'); + $warndetails = get_string('jobid', 'quiz_archiver').': '.$job->get_jobid(); - // Add artifact details if available + // Add artifact details if available. if ($artifactfile) { - $warn_details .= '
    '; - $warn_details .= get_string('quiz_archive', 'quiz_archiver').': ' .$artifactfile->get_filename().' ('.display_size($artifactfile->get_filesize()).')'; + $warndetails .= '
    '; + $warndetails .= get_string('quiz_archive', 'quiz_archiver').': ' .$artifactfile->get_filename(). + ' ('.display_size($artifactfile->get_filesize()).')'; } - // Warn additionally if job is scheduled for automatic deletion + // Warn additionally if job is scheduled for automatic deletion. if ($job->is_autodelete_enabled()) { if ($job->get_status() === ArchiveJob::STATUS_FINISHED) { - $warn_msg .= '

    '; - $warn_msg .= get_string( + $warnmsg .= '

    '; + $warnmsg .= get_string( 'delete_job_warning_retention', 'quiz_archiver', userdate($job->get_retentiontime(), get_string('strftimedatetime', 'langconfig')) @@ -72,29 +75,26 @@ public function definition() { } } - // Print warning element - $mform->addElement('html', << -

    $warn_head

    - $warn_msg -
    - $warn_details - - EOD); - - // Preserve internal information of mod_quiz + // Print warning element. + $mform->addElement('html', $OUTPUT->notification( + "

    $warnhead

    $warnmsg
    $warndetails", + \core\output\notification::NOTIFY_WARNING, + false, + )); + + // Preserve internal information of mod_quiz. $mform->addElement('hidden', 'id', $this->optional_param('id', null, PARAM_INT)); $mform->setType('id', PARAM_INT); $mform->addElement('hidden', 'mode', 'archiver'); $mform->setType('mode', PARAM_TEXT); - // Options + // Options. $mform->addElement('hidden', 'action', 'delete_job'); $mform->setType('action', PARAM_TEXT); $mform->addElement('hidden', 'jobid', $job->get_jobid()); $mform->setType('jobid', PARAM_TEXT); - // Action buttons + // Action buttons. $this->add_action_buttons(true, get_string('delete', 'moodle')); } diff --git a/classes/form/job_sign_form.php b/classes/form/job_sign_form.php index 33aea4a..71c7a1a 100644 --- a/classes/form/job_sign_form.php +++ b/classes/form/job_sign_form.php @@ -24,9 +24,10 @@ namespace quiz_archiver\form; -defined('MOODLE_INTERNAL') || die(); +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore -require_once($CFG->dirroot.'/lib/formslib.php'); + +require_once($CFG->dirroot.'/lib/formslib.php'); // @codeCoverageIgnore /** @@ -40,34 +41,32 @@ class job_sign_form extends \moodleform { * @throws \coding_exception */ public function definition() { + global $OUTPUT; $mform = $this->_form; - // Warning message - $warn_head = get_string('areyousure', 'moodle'); - $warn_msg = get_string('sign_archive_warning', 'quiz_archiver', $this->optional_param('jobid', null, PARAM_TEXT)); - $warn_details = get_string('jobid', 'quiz_archiver').': '.$this->optional_param('jobid', null, PARAM_TEXT); - $mform->addElement('html', << -

    $warn_head

    - $warn_msg -
    - $warn_details - - EOD); + // Warning message. + $warnhead = get_string('areyousure', 'moodle'); + $warnmsg = get_string('sign_archive_warning', 'quiz_archiver', $this->optional_param('jobid', null, PARAM_TEXT)); + $warndetails = get_string('jobid', 'quiz_archiver').': '.$this->optional_param('jobid', null, PARAM_TEXT); + $mform->addElement('html', $OUTPUT->notification( + "

    $warnhead

    $warnmsg
    $warndetails", + \core\output\notification::NOTIFY_INFO, + false, + )); - // Preserve internal information of mod_quiz + // Preserve internal information of mod_quiz. $mform->addElement('hidden', 'id', $this->optional_param('id', null, PARAM_INT)); $mform->setType('id', PARAM_INT); $mform->addElement('hidden', 'mode', 'archiver'); $mform->setType('mode', PARAM_TEXT); - // Options + // Options. $mform->addElement('hidden', 'action', 'sign_job'); $mform->setType('action', PARAM_TEXT); $mform->addElement('hidden', 'jobid', $this->optional_param('jobid', null, PARAM_TEXT)); $mform->setType('jobid', PARAM_TEXT); - // Action buttons + // Action buttons. $this->add_action_buttons(true, get_string('sign_archive', 'quiz_archiver')); } diff --git a/classes/local/admin/setting/admin_setting_archive_filename_pattern.php b/classes/local/admin/setting/admin_setting_archive_filename_pattern.php index bd93766..376c91d 100644 --- a/classes/local/admin/setting/admin_setting_archive_filename_pattern.php +++ b/classes/local/admin/setting/admin_setting_archive_filename_pattern.php @@ -18,12 +18,18 @@ use quiz_archiver\ArchiveJob; +// @codingStandardsIgnoreLine +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore + + /** * Custom admin setting for archive filename pattern input fields * + * @codeCoverageIgnore + * * @package quiz_archiver * @copyright 2024 Niels Gandraß - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or late + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class admin_setting_archive_filename_pattern extends \admin_setting_configtext { @@ -34,13 +40,13 @@ class admin_setting_archive_filename_pattern extends \admin_setting_configtext { * @throws \coding_exception */ public function validate($data) { - // Basic data validation + // Basic data validation. $parentvalidation = parent::validate($data); if ($parentvalidation !== true) { return $parentvalidation; } - // Validate filename pattern + // Validate filename pattern. if (!ArchiveJob::is_valid_archive_filename_pattern($data)) { return get_string('error_invalid_archive_filename_pattern', 'quiz_archiver'); } diff --git a/classes/local/admin/setting/admin_setting_attempt_filename_pattern.php b/classes/local/admin/setting/admin_setting_attempt_filename_pattern.php index 67e4039..bdbf488 100644 --- a/classes/local/admin/setting/admin_setting_attempt_filename_pattern.php +++ b/classes/local/admin/setting/admin_setting_attempt_filename_pattern.php @@ -18,12 +18,18 @@ use quiz_archiver\ArchiveJob; +// @codingStandardsIgnoreLine +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore + + /** * Custom admin setting for attempt filename pattern input fields * + * @codeCoverageIgnore + * * @package quiz_archiver * @copyright 2024 Niels Gandraß - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or late + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class admin_setting_attempt_filename_pattern extends \admin_setting_configtext { @@ -34,13 +40,13 @@ class admin_setting_attempt_filename_pattern extends \admin_setting_configtext { * @throws \coding_exception */ public function validate($data) { - // Basic data validation + // Basic data validation. $parentvalidation = parent::validate($data); if ($parentvalidation !== true) { return $parentvalidation; } - // Validate filename pattern + // Validate filename pattern. if (!ArchiveJob::is_valid_attempt_filename_pattern($data)) { return get_string('error_invalid_attempt_filename_pattern', 'quiz_archiver'); } diff --git a/classes/local/admin/setting/admin_setting_configcheckbox_alwaystrue.php b/classes/local/admin/setting/admin_setting_configcheckbox_alwaystrue.php index 3e47f44..61d0c66 100644 --- a/classes/local/admin/setting/admin_setting_configcheckbox_alwaystrue.php +++ b/classes/local/admin/setting/admin_setting_configcheckbox_alwaystrue.php @@ -16,13 +16,18 @@ namespace quiz_archiver\local\admin\setting; +// @codingStandardsIgnoreLine +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore + /** * Custom read-only admin setting checkbox that is always checked * + * @codeCoverageIgnore + * * @package quiz_archiver * @copyright 2024 Niels Gandraß - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or late + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class admin_setting_configcheckbox_alwaystrue extends \admin_setting_configcheckbox { diff --git a/classes/local/autoinstall.php b/classes/local/autoinstall.php new file mode 100644 index 0000000..e167f5e --- /dev/null +++ b/classes/local/autoinstall.php @@ -0,0 +1,309 @@ +. + +namespace quiz_archiver\local; + +// @codingStandardsIgnoreLine +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore + + +// @codeCoverageIgnoreStart +require_once("{$CFG->dirroot}/user/lib.php"); +require_once("{$CFG->dirroot}/webservice/lib.php"); +require_once("{$CFG->dirroot}/lib/adminlib.php"); +// @codeCoverageIgnoreEnd + +use coding_exception; +use context_system; +use dml_exception; +use webservice; + +/** + * Autoinstall routines for the quiz_archiver plugin + * + * @package quiz_archiver + * @copyright 2024 Niels Gandraß + * 2024 Melanie Treitinger, Ruhr-Universität Bochum + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class autoinstall { + + /** @var string Default name for the webservice to create */ + const DEFAULT_WSNAME = 'quiz_archiver_webservice'; + + /** @var string Default username for the service account to create */ + const DEFAULT_USERNAME = 'quiz_archiver_serviceaccount'; + + /** @var string Default shortname for the role to create */ + const DEFAULT_ROLESHORTNAME = 'quiz_archiver'; + + /** @var string[] List of capabilities to assign to the created role */ + const WS_ROLECAPS = [ + 'mod/quiz:reviewmyattempts', + 'mod/quiz:view', + 'mod/quiz:viewreports', + 'mod/quiz_archiver:use_webservice', + 'moodle/backup:anonymise', + 'moodle/backup:backupactivity', + 'moodle/backup:backupcourse', + 'moodle/backup:backupsection', + 'moodle/backup:backuptargetimport', + 'moodle/backup:configure', + 'moodle/backup:downloadfile', + 'moodle/backup:userinfo', + 'moodle/course:ignoreavailabilityrestrictions', + 'moodle/course:view', + 'moodle/course:viewhiddenactivities', + 'moodle/course:viewhiddencourses', + 'moodle/course:viewhiddensections', + 'moodle/user:ignoreuserquota', + 'webservice/rest:use', + ]; + + /** @var string[] List of functions to add to the created webservice */ + const WS_FUNCTIONS = [ + 'quiz_archiver_generate_attempt_report', + 'quiz_archiver_get_attempts_metadata', + 'quiz_archiver_update_job_status', + 'quiz_archiver_process_uploaded_artifact', + 'quiz_archiver_get_backup_status', + ]; + + /** + * Determines if the quiz_archiver plugin was configured previously. + * + * @return bool True if the plugin is unconfigured, false otherwise + * @throws dml_exception If the plugin configuration cannot be retrieved + */ + public static function plugin_is_unconfigured(): bool { + return intval(get_config('quiz_archiver', 'webservice_id')) <= 0 + && intval(get_config('quiz_archiver', 'webservice_userid')) <= 0; + } + + /** + * Performs an automatic installation of the quiz_archiver plugin. + * + * This function: + * - Enables web services and REST protocol + * - Creates a quiz archiver service role and a corresponding user + * - Creates a new web service with all required webservice functions + * - Authorises the user to use the webservice. + * + * @param string $workerurl The URL of the quiz archive worker service + * @param string $wsname The name for the web service to create + * @param string $rolename The shortname for the role to create + * @param string $username The username for the service account to create + * @param bool $force If true, the installation is forced regardless of the + * current state of the system + * @return array An array with two elements: a boolean indicating success + * and a string with a log of the performed actions + */ + public static function execute( + string $workerurl, + string $wsname = self::DEFAULT_WSNAME, + string $rolename = self::DEFAULT_ROLESHORTNAME, + string $username = self::DEFAULT_USERNAME, + bool $force = false + ): array { + // Prepare return values. + $success = false; + + try { + // Init log array. + $log = []; + + // Ensure current user is an admin. + if (!is_siteadmin()) { + $log[] = "Error: You need to be a site administrator to run this script."; + throw new \RuntimeException(); + } + + // Check if the plugin is already configured. + if (!self::plugin_is_unconfigured()) { + if ($force) { + $log[] = "Warning: The quiz archiver plugin is already configured. Forcing reconfiguration nonetheless ..."; + } else { + $log[] = "Error: The quiz archiver plugin is already configured. Use --force to bypass this check."; + throw new \RuntimeException(); + } + } + + // Apply default values for all plugin settings. + $adminroot = admin_get_root(); + $adminsearch = $adminroot->search('quiz_archiver_settings'); + if (!$adminsearch || !$adminsearch['quiz_archiver_settings']->page) { + $log[] = "Error: Could not find admin settings definitions for quiz archiver plugin."; + throw new \RuntimeException(); + } + $adminpage = $adminsearch['quiz_archiver_settings']->page; + $appliedsettings = admin_apply_default_settings($adminpage); + if (count($appliedsettings) < 1) { + $log[] = "Error: Could not apply default settings for quiz archiver plugin."; + throw new \RuntimeException(); + } else { + $log[] = " -> Default plugin settings applied."; + } + + // Check worker URL. + if (empty($workerurl)) { + $log[] = "Error: The given worker URL is invalid."; + throw new \RuntimeException(); + } + + // Get system context. + try { + $systemcontext = context_system::instance(); + } catch (dml_exception $e) { + $log[] = "Error: Cannot get system context: ".$e->getMessage(); + throw new \RuntimeException(); + } + + // Create a web service user. + try { + $webserviceuserid = user_create_user([ + 'auth' => 'manual', + 'username' => $username, + 'password' => bin2hex(random_bytes(28))."#1A", + 'firstname' => 'Quiz Archiver', + 'lastname' => 'Service Account', + 'email' => 'noreply@localhost', + 'confirmed' => 1, + 'deleted' => 0, + 'policyagreed' => 1, + ]); + $webserviceuser = \core_user::get_user($webserviceuserid); + $log[] = " -> Web service user '{$webserviceuser->username}' with ID {$webserviceuser->id} created."; + } catch (dml_exception $e) { + $log[] = "Error: Cloud not create webservice user: ".$e->getMessage(); + throw new \RuntimeException(); + } catch (\Exception $e) { // Random\RandomException is only thrown with PHP >= 8.2, generic \Exception otherwise. + $log[] = "Error: Could not create webservice user: ".$e->getMessage(); + throw new \RuntimeException(); + } + + // Create a web service role. + try { + $wsroleid = create_role( + 'Quiz Archiver Service Account', + $rolename, + 'A role that bundles all access rights required for the quiz archiver plugin to work.' + ); + set_role_contextlevels($wsroleid, [CONTEXT_SYSTEM]); + + $log[] = " -> Role '{$rolename}' created."; + } catch (coding_exception $e) { + $log[] = "Error: Cannot create role {$rolename}: {$e->getMessage()}"; + throw new \RuntimeException(); + } + + foreach (self::WS_ROLECAPS as $cap) { + try { + assign_capability($cap, CAP_ALLOW, $wsroleid, $systemcontext->id, true); + $log[] = " -> Capability {$cap} assigned to role '{$rolename}'."; + } catch (coding_exception $e) { + $log[] = "Error: Cannot assign capability {$cap}: {$e->getMessage()}"; + throw new \RuntimeException(); + } + } + + // Give the user the role. + try { + role_assign($wsroleid, $webserviceuser->id, $systemcontext->id); + $log[] = " -> Role '{$rolename}' assigned to user '{$webserviceuser->username}'."; + } catch (coding_exception $e) { + $log[] = "Error: Cannot assign role to webservice user: ".$e->getMessage(); + throw new \RuntimeException(); + } + + // Enable web services and REST protocol. + try { + set_config('enablewebservices', true); + $log[] = " -> Web services enabled."; + + $enabledprotocols = get_config('core', 'webserviceprotocols'); + if (stripos($enabledprotocols, 'rest') === false) { + set_config('webserviceprotocols', $enabledprotocols . ',rest'); + } + $log[] = " -> REST webservice protocol enabled."; + } catch (dml_exception $e) { + $log[] = "Error: Cannot get config setting webserviceprotocols: ".$e->getMessage(); + throw new \RuntimeException(); + } + + // Enable the webservice. + $webservicemanager = new webservice(); + $serviceid = $webservicemanager->add_external_service((object)[ + 'name' => $wsname, + 'shortname' => $wsname, + 'enabled' => 1, + 'requiredcapability' => '', + 'restrictedusers' => true, + 'downloadfiles' => true, + 'uploadfiles' => true, + ]); + + if (!$serviceid) { + $log[] = "Error: Service {$wsname} could not be created."; + throw new \RuntimeException(); + } else { + $log[] = " -> Web service '{$wsname}' created with ID {$serviceid}."; + } + + // Add functions to the service. + foreach (self::WS_FUNCTIONS as $f) { + $webservicemanager->add_external_function_to_service($f, $serviceid); + $log[] = " -> Function {$f} added to service '{$wsname}'."; + } + + // Authorise the user to use the service. + $webservicemanager->add_ws_authorised_user((object) [ + 'externalserviceid' => $serviceid, + 'userid' => $webserviceuser->id, + ]); + + $service = $webservicemanager->get_external_service_by_id($serviceid); + $webservicemanager->update_external_service($service); + $log[] = " -> User '{$webserviceuser->username}' authorised to use service '{$wsname}'."; + + // Configure quiz_archiver plugin settings. + try { + $log[] = " -> Configuring the quiz archiver plugin..."; + + set_config('webservice_id', $serviceid, 'quiz_archiver'); + $log[] = " -> Web service set to '{$wsname}'."; + + set_config('webservice_userid', $webserviceuser->id, 'quiz_archiver'); + $log[] = " -> Web service user set to '{$webserviceuser->username}'."; + + set_config('worker_url', $workerurl, 'quiz_archiver'); + $log[] = " -> Worker URL set to '{$workerurl}'."; + } catch (\Exception $e) { + $log[] = "Error: Failed to set config settings for quiz_archiver plugin: ".$e->getMessage(); + throw new \RuntimeException(); + } + + $success = true; + } catch (\RuntimeException $e) { + $success = false; + } catch (\Exception $e) { + $success = false; + $log[] = "Error: An unexpected error occurred: ".$e->getMessage(); + } finally { + return [$success, implode("\r\n", $log)]; + } + } + +} diff --git a/classes/local/util.php b/classes/local/util.php index 9a07e97..33df3b1 100644 --- a/classes/local/util.php +++ b/classes/local/util.php @@ -16,12 +16,16 @@ namespace quiz_archiver\local; +// @codingStandardsIgnoreLine +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore + + /** * Custom util functions * * @package quiz_archiver * @copyright 2024 Niels Gandraß - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or late + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class util { @@ -32,15 +36,25 @@ class util { * @return string Human readable duration string */ public static function duration_to_human_readable(int $duration): string { - // Calculate isolated time units + // Calculate isolated time units. $years = floor($duration / YEARSECS); - $months = floor(($duration % YEARSECS) / (YEARSECS / 12)); - $days = floor(($duration % (YEARSECS / 12)) / DAYSECS); - $hours = floor(($duration % DAYSECS) / HOURSECS); - $minutes = floor(($duration % HOURSECS) / MINSECS); + $duration -= $years * YEARSECS; + + $months = floor($duration / (YEARSECS / 12)); + $duration -= $months * (YEARSECS / 12); + + $days = floor($duration / DAYSECS); + $duration -= $days * DAYSECS; + + $hours = floor($duration / HOURSECS); + $duration -= $hours * HOURSECS; + + $minutes = floor($duration / MINSECS); + $duration -= $minutes * MINSECS; + $seconds = floor($duration % MINSECS); - // Generate human readable string + // Generate human readable string. $humanreadable = ''; if ($years > 0) { $humanreadable .= $years . 'y '; @@ -61,6 +75,10 @@ public static function duration_to_human_readable(int $duration): string { $humanreadable .= $seconds . 's '; } + if (!$humanreadable) { + $humanreadable = '0s'; + } + return trim($humanreadable); } @@ -88,4 +106,4 @@ public static function duration_to_unit(int $duration): array { return [$duration, get_string('seconds')]; } -} \ No newline at end of file +} diff --git a/classes/output/job_overview_table.php b/classes/output/job_overview_table.php index fe2e84f..64b807c 100644 --- a/classes/output/job_overview_table.php +++ b/classes/output/job_overview_table.php @@ -26,10 +26,13 @@ use quiz_archiver\ArchiveJob; -defined('MOODLE_INTERNAL') || die(); +// @codingStandardsIgnoreLine +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore +// @codeCoverageIgnoreStart global $CFG; require_once($CFG->libdir.'/tablelib.php'); +// @codeCoverageIgnoreEnd /** @@ -52,25 +55,28 @@ public function __construct(string $uniqueid, int $courseid, int $cmid, int $qui parent::__construct($uniqueid); $this->define_columns([ 'timecreated', - 'status', 'user', 'jobid', 'filesize', + 'status', 'actions', ]); $this->define_headers([ get_string('task_starttime', 'admin'), - get_string('status'), get_string('user'), get_string('jobid', 'quiz_archiver'), get_string('size'), + get_string('status'), '', ]); $this->set_sql( - 'j.jobid, j.userid, j.timecreated, j.timemodified, j.status, j.retentiontime, j.artifactfilechecksum, f.pathnamehash, f.filesize, u.username', - '{'.ArchiveJob::JOB_TABLE_NAME.'} AS j JOIN {user} AS u ON j.userid = u.id LEFT JOIN {files} AS f ON j.artifactfileid = f.id', + 'j.jobid, j.userid, j.timecreated, j.timemodified, j.status, j.statusextras, j.retentiontime, j.artifactfilechecksum, '. + 'f.pathnamehash, f.filesize, u.username', + '{'.ArchiveJob::JOB_TABLE_NAME.'} j '. + 'JOIN {user} u ON j.userid = u.id '. + 'LEFT JOIN {files} f ON j.artifactfileid = f.id', 'j.courseid = :courseid AND j.cmid = :cmid AND j.quizid = :quizid', [ 'courseid' => $courseid, @@ -103,8 +109,24 @@ public function col_timecreated($values) { * @throws \coding_exception */ public function col_status($values) { - $s = ArchiveJob::get_status_display_args($values->status); - return ''.$s['text'].'
    '.date('H:i:s', $values->timemodified).''; + $html = ''; + $s = ArchiveJob::get_status_display_args( + $values->status, + $values->statusextras ? json_decode($values->statusextras, true) : null + ); + + $statustooltiphtml = 'data-toggle="tooltip" data-placement="top" title="'.$s['help'].'"'; + $html .= ''.$s['text'].'
    '; + + if (isset($s['statusextras']['progress'])) { + $html .= ''; + $html .= ' '.$s['statusextras']['progress'].'%'; + $html .= '
    '; + } + + $html .= ''.date('H:i:s', $values->timemodified).''; + + return $html; } /** @@ -140,10 +162,11 @@ public function col_filesize($values) { public function col_actions($values) { $html = ''; - // Action: Show details + // Action: Show details. + // @codingStandardsIgnoreLine $html .= ''; - // Action: Download + // Action: Download. if ($values->pathnamehash) { $artifactfile = get_file_storage()->get_file_by_hash($values->pathnamehash); $artifacturl = \moodle_url::make_pluginfile_url( @@ -156,19 +179,23 @@ public function col_actions($values) { true, ); - $download_title = get_string('download').': '.$artifactfile->get_filename().' ('.get_string('size').': '.display_size($artifactfile->get_filesize()).')'; - $html .= ''; + $downloadtitle = get_string('download').': '.$artifactfile->get_filename(). + ' ('.get_string('size').': '.display_size($artifactfile->get_filesize()).')'; + // @codingStandardsIgnoreLine + $html .= ''; } else { + // @codingStandardsIgnoreLine $html .= ''; } - // Action: Delete + // Action: Delete. $deleteurl = new \moodle_url('', [ 'id' => optional_param('id', null, PARAM_INT), 'mode' => 'archiver', 'action' => 'delete_job', 'jobid' => $values->jobid, ]); + // @codingStandardsIgnoreLine $html .= ''; return $html; diff --git a/classes/privacy/provider.php b/classes/privacy/provider.php index 6067029..4ed856d 100644 --- a/classes/privacy/provider.php +++ b/classes/privacy/provider.php @@ -34,10 +34,14 @@ use quiz_archiver\FileManager; use quiz_archiver\TSPManager; -defined('MOODLE_INTERNAL') || die(); +// @codingStandardsIgnoreLine +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore + /** * Privacy provider for quiz_archiver + * + * @codeCoverageIgnore This is handled by Moodle core tests */ class provider implements \core_privacy\local\metadata\provider, @@ -51,10 +55,10 @@ class provider implements * @return collection A listing of user data stored through this system. */ public static function get_metadata(collection $collection): collection { - // Quiz archive files + // Quiz archive files. $collection->add_subsystem_link('core_files', [], 'privacy:metadata:core_files'); - // Database tables + // Database tables. $collection->add_database_table('quiz_archiver_jobs', [ 'courseid' => 'privacy:metadata:quiz_archiver_jobs:courseid', 'cmid' => 'privacy:metadata:quiz_archiver_jobs:cmid', @@ -88,9 +92,9 @@ public static function get_metadata(collection $collection): collection { public static function get_contexts_for_userid(int $userid): contextlist { $contextlist = new contextlist(); - // Get all contexts where the user has a quiz archiver job + // Get all contexts where the user has a quiz archiver job. // Note: The context stays the same across all entries for a single - // archive job. Hence, we only query the main job table. + // archive job. Hence, we only query the main job table. $contextlist->add_from_sql(" SELECT DISTINCT c.id FROM {context} c @@ -107,7 +111,7 @@ public static function get_contexts_for_userid(int $userid): contextlist { ] ); - // Add all contexts where the user is part of a quiz archive + // Add all contexts where the user is part of a quiz archive. $contextlist->add_from_sql(" SELECT DISTINCT c.id FROM {context} c @@ -141,12 +145,10 @@ public static function export_user_data(approved_contextlist $contextlist) { $userid = $contextlist->get_user()->id; - // Process all contexts - $subCtxBase = get_string('pluginname', 'quiz_archiver'); + // Process all contexts. + $subctxbase = get_string('pluginname', 'quiz_archiver'); foreach ($contextlist->get_contexts() as $ctx) { - $ctxData = []; - - // Get existing jobs for current context + // Get existing jobs for current context. $jobs = $DB->get_records_sql(" SELECT * FROM {context} c @@ -163,54 +165,54 @@ public static function export_user_data(approved_contextlist $contextlist) { 'userid' => $userid, ]); - // Export each job + // Export each job. foreach ($jobs as $job) { - // Set correct subcontext for the job - $subCtx = [$subCtxBase, "Job: {$job->jobid}"]; + // Set correct subcontext for the job. + $subctx = [$subctxbase, "Job: {$job->jobid}"]; - // Get job settings - $job_settings = $DB->get_records( + // Get job settings. + $jobsettings = $DB->get_records( ArchiveJob::JOB_SETTINGS_TABLE_NAME, ['jobid' => $job->id], '', 'key, value' ); - // Get TSP data - $tsp_data = $DB->get_record( + // Get TSP data. + $tspdata = $DB->get_record( TSPManager::TSP_TABLE_NAME, ['jobid' => $job->id], 'timecreated, server, timestampquery, timestampreply', IGNORE_MISSING ); - // Encode TSP data as base64 if present - if ($tsp_data) { - $tsp_data->timestampquery = base64_encode($tsp_data->timestampquery); - $tsp_data->timestampreply = base64_encode($tsp_data->timestampreply); + // Encode TSP data as base64 if present. + if ($tspdata) { + $tspdata->timestampquery = base64_encode($tspdata->timestampquery); + $tspdata->timestampreply = base64_encode($tspdata->timestampreply); } - // Add job data to current context - writer::with_context($ctx)->export_data($subCtx, (object) [ + // Add job data to current context. + writer::with_context($ctx)->export_data($subctx, (object) [ 'courseid' => $job->courseid, 'cmid' => $job->cmid, 'quizid' => $job->quizid, 'userid' => $job->userid, 'timecreated' => $job->timecreated, 'timemodified' => $job->timemodified, - 'settings' => $job_settings, - 'tsp' => $tsp_data, + 'settings' => $jobsettings, + 'tsp' => $tspdata, ]); if ($job->artifactfileid) { writer::with_context($ctx)->export_file( - $subCtx, + $subctx, get_file_storage()->get_file_by_id($job->artifactfileid) ); } } - // Process artifact files for the user in the given context + // Process artifact files for the user in the given context. $attemptartifacts = $DB->get_records_sql(" SELECT a.id, j.id AS jobid, j.courseid, j.cmid, j.quizid, j.artifactfileid, a.attemptid FROM {context} c @@ -234,7 +236,7 @@ public static function export_user_data(approved_contextlist $contextlist) { $archive = $fm->extract_attempt_data_from_artifact($artifact, $row->jobid, $row->attemptid); if ($archive) { - writer::with_context($ctx)->export_file([$subCtxBase, "Attempts"], $archive); + writer::with_context($ctx)->export_file([$subctxbase, "Attempts"], $archive); } } } @@ -252,7 +254,7 @@ public static function get_users_in_context(userlist $userlist) { return; } - // Job metadata + // Job metadata. $userlist->add_from_sql( 'userid', " @@ -269,7 +271,7 @@ public static function get_users_in_context(userlist $userlist) { ] ); - // Quiz archive file contents + // Quiz archive file contents. $userlist->add_from_sql( 'userid', " @@ -294,7 +296,7 @@ public static function get_users_in_context(userlist $userlist) { * @param approved_userlist $userlist The approved context and user information to delete information for. */ public static function delete_data_for_users(approved_userlist $userlist) { - // We cannot simply delete data that needs to be archived for a specified amount of time + // We cannot simply delete data that needs to be archived for a specified amount of time. } /** @@ -303,7 +305,7 @@ public static function delete_data_for_users(approved_userlist $userlist) { * @param \context $context The specific context to delete data for. */ public static function delete_data_for_all_users_in_context(\context $context) { - // We cannot simply delete data that needs to be archived for a specified amount of time + // We cannot simply delete data that needs to be archived for a specified amount of time. } /** @@ -312,7 +314,7 @@ public static function delete_data_for_all_users_in_context(\context $context) { * @param approved_contextlist $contextlist The approved contexts and user information to delete information for. */ public static function delete_data_for_user(approved_contextlist $contextlist) { - // We cannot simply delete data that needs to be archived for a specified amount of time + // We cannot simply delete data that needs to be archived for a specified amount of time. } } diff --git a/classes/task/autodelete_job_artifacts.php b/classes/task/autodelete_job_artifacts.php index cc6e1ac..793a016 100644 --- a/classes/task/autodelete_job_artifacts.php +++ b/classes/task/autodelete_job_artifacts.php @@ -26,11 +26,15 @@ use quiz_archiver\ArchiveJob; -defined('MOODLE_INTERNAL') || die(); +// @codingStandardsIgnoreLine +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore + /** * Scheduled task to delete job artifacts that have expired their retention time. + * + * @codeCoverageIgnore This is just a wrapper for ArchiveJob::delete_expired_artifacts() */ class autodelete_job_artifacts extends \core\task\scheduled_task { @@ -52,8 +56,8 @@ public function get_name(): string { */ public function execute(): void { echo get_string('task_autodelete_job_artifacts_start', 'quiz_archiver') . "\n"; - $files_deleted = ArchiveJob::delete_expired_artifacts(); - echo get_string('task_autodelete_job_artifacts_report', 'quiz_archiver', $files_deleted) . "\n"; + $numfilesdeleted = ArchiveJob::delete_expired_artifacts(); + echo get_string('task_autodelete_job_artifacts_report', 'quiz_archiver', $numfilesdeleted) . "\n"; } -} \ No newline at end of file +} diff --git a/classes/task/cleanup_temp_files.php b/classes/task/cleanup_temp_files.php index 5dd4b20..d989804 100644 --- a/classes/task/cleanup_temp_files.php +++ b/classes/task/cleanup_temp_files.php @@ -26,11 +26,15 @@ use quiz_archiver\FileManager; -defined('MOODLE_INTERNAL') || die(); +// @codingStandardsIgnoreLine +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore + /** * Scheduled task to periodically clean up temporary files. + * + * @codeCoverageIgnore This is just a wrapper for FileManager::cleanup_temp_files() */ class cleanup_temp_files extends \core\task\scheduled_task { @@ -51,8 +55,8 @@ public function get_name(): string { */ public function execute(): void { echo get_string('task_cleanup_temp_files_start', 'quiz_archiver') . "\n"; - $files_deleted = FileManager::cleanup_temp_files(); - echo get_string('task_cleanup_temp_files_report', 'quiz_archiver', $files_deleted) . "\n"; + $numfilesdeleted = FileManager::cleanup_temp_files(); + echo get_string('task_cleanup_temp_files_report', 'quiz_archiver', $numfilesdeleted) . "\n"; } -} \ No newline at end of file +} diff --git a/cli/autoinstall.php b/cli/autoinstall.php new file mode 100644 index 0000000..4900b83 --- /dev/null +++ b/cli/autoinstall.php @@ -0,0 +1,112 @@ +. + +/** + * quiz_archiver - automatic install script + * + * @package quiz_archiver + * @copyright 2024 Niels Gandraß + * 2024 Melanie Treitinger, Ruhr-Universität Bochum + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +// Must be kept at old define syntax because Moodle CodeSniffer profile does not detect new const syntax properly. +define('CLI_SCRIPT', true); + +require_once(__DIR__ . '/../../../../../config.php'); +require_once("{$CFG->libdir}/clilib.php"); +require_once("{$CFG->dirroot}/mod/quiz/report/archiver/classes/local/autoinstall.php"); + +use quiz_archiver\local\autoinstall; + +// XXX-> CLI options parsing. + +list($options, $unrecognised) = cli_get_params( + [ + 'help' => false, + 'workerurl' => 'http://localhost:8080', + 'wsname' => autoinstall::DEFAULT_WSNAME, + 'rolename' => autoinstall::DEFAULT_ROLESHORTNAME, + 'username' => autoinstall::DEFAULT_USERNAME, + 'force' => false, + ], + [ + 'h' => 'help', + 'f' => 'force', + ] +); + +$usage = << Sets the URL of the worker (default: http://localhost:8080) + --wsname= Sets a custom name for the web service (default: quiz_archiver_webservice) + --rolename= Sets a custom name for the web service role (default: quiz_archiver) + --username= Sets a custom username for the web service user (default: quiz_archiver_serviceaccount) +EOT; + +if ($unrecognised) { + $unrecognised = implode(PHP_EOL . ' ', $unrecognised); + cli_error(get_string('cliunknowoption', 'core_admin', $unrecognised)); +} + +if ($options['help']) { + cli_writeln($usage); + exit(2); +} + +// XXX-> Begin of autoinstall routine. + +// Set admin user. +$USER = get_admin(); + +cli_writeln("Starting automatic installation of quiz archiver plugin..."); +cli_separator(); + +list($success, $log) = autoinstall::execute( + $options['workerurl'], + $options['wsname'], + $options['rolename'], + $options['username'], + $options['force'] +); + +cli_write($log."\r\n"); + +if ($success) { + cli_separator(); + cli_writeln("Automatic installation of quiz archiver plugin finished successfully."); + exit(0); +} else { + cli_writeln("Aborted."); + cli_separator(); + cli_writeln("FAILED: Automatic installation of quiz archiver plugin failed."); + exit(1); +} diff --git a/db/access.php b/db/access.php index 35f472a..68e88b6 100644 --- a/db/access.php +++ b/db/access.php @@ -22,7 +22,9 @@ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -defined('MOODLE_INTERNAL') || die(); +// @codingStandardsIgnoreLine +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore + $capabilities = [ // Capability to view the quiz archiver report page. diff --git a/db/install.php b/db/install.php index 01e11e0..b32da53 100644 --- a/db/install.php +++ b/db/install.php @@ -23,12 +23,26 @@ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -defined('MOODLE_INTERNAL') || die(); +// @codingStandardsIgnoreLine +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore + /** * Custom code to be run on installing the plugin. */ function xmldb_quiz_archiver_install() { + // Print welcome message. + $autoinstallurl = new moodle_url('/mod/quiz/report/archiver/adminui/autoinstall.php'); + $pluginsettingsurl = new moodle_url('/admin/settings.php', ['section' => 'quiz_archiver_settings']); + + echo ''; return true; } diff --git a/db/install.xml b/db/install.xml index db3cd94..f1a537f 100644 --- a/db/install.xml +++ b/db/install.xml @@ -1,5 +1,5 @@ - + @@ -10,6 +10,7 @@ + diff --git a/db/services.php b/db/services.php index a57a6aa..3dfa4bc 100644 --- a/db/services.php +++ b/db/services.php @@ -22,7 +22,9 @@ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -defined('MOODLE_INTERNAL') || die(); +// @codingStandardsIgnoreLine +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore + $functions = [ 'quiz_archiver_generate_attempt_report' => [ diff --git a/db/tasks.php b/db/tasks.php index 053300e..2e2d2ec 100644 --- a/db/tasks.php +++ b/db/tasks.php @@ -22,6 +22,10 @@ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +// @codingStandardsIgnoreLine +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore + + $tasks = [ [ 'classname' => 'quiz_archiver\task\cleanup_temp_files', @@ -41,4 +45,4 @@ 'month' => '*', 'dayofweek' => '*', ], -]; \ No newline at end of file +]; diff --git a/db/uninstall.php b/db/uninstall.php index 049f5b6..29dfdb3 100644 --- a/db/uninstall.php +++ b/db/uninstall.php @@ -23,12 +23,13 @@ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -defined('MOODLE_INTERNAL') || die(); +// @codingStandardsIgnoreLine +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore + /** * Custom uninstallation procedure. */ function xmldb_quiz_archiver_uninstall() { - return true; } diff --git a/db/upgrade.php b/db/upgrade.php index b3be3d8..805f72d 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -23,12 +23,20 @@ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -defined('MOODLE_INTERNAL') || die(); +// @codingStandardsIgnoreLine +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore + /** * Custom code to be run to update the plugin database. * * @param int $oldversion The version we are upgrading from + * @return true + * @throws ddl_exception + * @throws ddl_field_missing_exception + * @throws ddl_table_missing_exception + * @throws downgrade_exception + * @throws upgrade_exception */ function xmldb_quiz_archiver_upgrade($oldversion) { global $DB; @@ -74,25 +82,25 @@ function xmldb_quiz_archiver_upgrade($oldversion) { if ($oldversion < 2023072700) { // Replace foreign-unique key with simple foreign key for userid in quiz_report_archiver_jobs. $table = new xmldb_table('quiz_report_archiver_jobs'); - $old_key = new xmldb_key('userid', XMLDB_KEY_FOREIGN_UNIQUE, ['userid'], 'user', ['id']); - $new_key = new xmldb_key('userid', XMLDB_KEY_FOREIGN, ['userid'], 'user', ['id']); + $oldkey = new xmldb_key('userid', XMLDB_KEY_FOREIGN_UNIQUE, ['userid'], 'user', ['id']); + $newkey = new xmldb_key('userid', XMLDB_KEY_FOREIGN, ['userid'], 'user', ['id']); // Perform key exchange. - $dbman->drop_key($table, $old_key); - $dbman->add_key($table, $new_key); + $dbman->drop_key($table, $oldkey); + $dbman->add_key($table, $newkey); // Archiver savepoint reached. upgrade_plugin_savepoint(true, 2023072700, 'quiz', 'archiver'); } if ($oldversion < 2023080104) { - // Remove foreign key constraints with reftables to be renamed + // Remove foreign key constraints with reftables to be renamed. $dbman->drop_key( new xmldb_table('quiz_report_archiver_files'), new xmldb_key('jobid', XMLDB_KEY_FOREIGN, ['jobid'], 'quiz_report_archiver_jobs', ['id']) ); - // Rename tables to remove the "report_" prefix + // Rename tables to remove the "report_" prefix. $dbman->rename_table( new xmldb_table('quiz_report_archiver_jobs'), 'quiz_archiver_jobs' @@ -102,7 +110,7 @@ function xmldb_quiz_archiver_upgrade($oldversion) { 'quiz_archiver_files' ); - // Restore foreign key constraints + // Restore foreign key constraints. $dbman->add_key( new xmldb_table('quiz_archiver_files'), new xmldb_key('jobid', XMLDB_KEY_FOREIGN, ['jobid'], 'quiz_archiver_jobs', ['id']) @@ -212,6 +220,19 @@ function xmldb_quiz_archiver_upgrade($oldversion) { upgrade_plugin_savepoint(true, 2024011000, 'quiz', 'archiver'); } + if ($oldversion < 2024072200) { + // Define field statusextras to be added to quiz_archiver_jobs. + $table = new xmldb_table('quiz_archiver_jobs'); + $field = new xmldb_field('statusextras', XMLDB_TYPE_TEXT, null, null, null, null, null, 'status'); + + // Conditionally launch add field statusextras. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Archiver savepoint reached. + upgrade_plugin_savepoint(true, 2024072200, 'quiz', 'archiver'); + } return true; } diff --git a/doc/configuration/configuration_archive_job_presets_2.png b/docs/assets/configuration/configuration_archive_job_presets_2.png similarity index 100% rename from doc/configuration/configuration_archive_job_presets_2.png rename to docs/assets/configuration/configuration_archive_job_presets_2.png diff --git a/doc/configuration/configuration_archive_job_presets_2_thumb.png b/docs/assets/configuration/configuration_archive_job_presets_2_thumb.png similarity index 100% rename from doc/configuration/configuration_archive_job_presets_2_thumb.png rename to docs/assets/configuration/configuration_archive_job_presets_2_thumb.png diff --git a/doc/configuration/configuration_archive_job_presets_3.png b/docs/assets/configuration/configuration_archive_job_presets_3.png similarity index 100% rename from doc/configuration/configuration_archive_job_presets_3.png rename to docs/assets/configuration/configuration_archive_job_presets_3.png diff --git a/doc/configuration/configuration_archive_job_presets_3_thumb.png b/docs/assets/configuration/configuration_archive_job_presets_3_thumb.png similarity index 100% rename from doc/configuration/configuration_archive_job_presets_3_thumb.png rename to docs/assets/configuration/configuration_archive_job_presets_3_thumb.png diff --git a/doc/configuration/configuration_assign_role_1.png b/docs/assets/configuration/configuration_assign_role_1.png similarity index 100% rename from doc/configuration/configuration_assign_role_1.png rename to docs/assets/configuration/configuration_assign_role_1.png diff --git a/doc/configuration/configuration_assign_role_1_thumb.png b/docs/assets/configuration/configuration_assign_role_1_thumb.png similarity index 100% rename from doc/configuration/configuration_assign_role_1_thumb.png rename to docs/assets/configuration/configuration_assign_role_1_thumb.png diff --git a/doc/configuration/configuration_assign_role_2.png b/docs/assets/configuration/configuration_assign_role_2.png similarity index 100% rename from doc/configuration/configuration_assign_role_2.png rename to docs/assets/configuration/configuration_assign_role_2.png diff --git a/doc/configuration/configuration_assign_role_2_thumb.png b/docs/assets/configuration/configuration_assign_role_2_thumb.png similarity index 100% rename from doc/configuration/configuration_assign_role_2_thumb.png rename to docs/assets/configuration/configuration_assign_role_2_thumb.png diff --git a/doc/configuration/configuration_assign_role_3.png b/docs/assets/configuration/configuration_assign_role_3.png similarity index 100% rename from doc/configuration/configuration_assign_role_3.png rename to docs/assets/configuration/configuration_assign_role_3.png diff --git a/doc/configuration/configuration_assign_role_3_thumb.png b/docs/assets/configuration/configuration_assign_role_3_thumb.png similarity index 100% rename from doc/configuration/configuration_assign_role_3_thumb.png rename to docs/assets/configuration/configuration_assign_role_3_thumb.png diff --git a/doc/configuration/configuration_assign_webservice_functions_1.png b/docs/assets/configuration/configuration_assign_webservice_functions_1.png similarity index 100% rename from doc/configuration/configuration_assign_webservice_functions_1.png rename to docs/assets/configuration/configuration_assign_webservice_functions_1.png diff --git a/doc/configuration/configuration_assign_webservice_functions_1_thumb.png b/docs/assets/configuration/configuration_assign_webservice_functions_1_thumb.png similarity index 100% rename from doc/configuration/configuration_assign_webservice_functions_1_thumb.png rename to docs/assets/configuration/configuration_assign_webservice_functions_1_thumb.png diff --git a/doc/configuration/configuration_assign_webservice_functions_2.png b/docs/assets/configuration/configuration_assign_webservice_functions_2.png similarity index 100% rename from doc/configuration/configuration_assign_webservice_functions_2.png rename to docs/assets/configuration/configuration_assign_webservice_functions_2.png diff --git a/doc/configuration/configuration_assign_webservice_functions_2_thumb.png b/docs/assets/configuration/configuration_assign_webservice_functions_2_thumb.png similarity index 100% rename from doc/configuration/configuration_assign_webservice_functions_2_thumb.png rename to docs/assets/configuration/configuration_assign_webservice_functions_2_thumb.png diff --git a/doc/configuration/configuration_assign_webservice_functions_3.png b/docs/assets/configuration/configuration_assign_webservice_functions_3.png similarity index 100% rename from doc/configuration/configuration_assign_webservice_functions_3.png rename to docs/assets/configuration/configuration_assign_webservice_functions_3.png diff --git a/doc/configuration/configuration_assign_webservice_functions_3_thumb.png b/docs/assets/configuration/configuration_assign_webservice_functions_3_thumb.png similarity index 100% rename from doc/configuration/configuration_assign_webservice_functions_3_thumb.png rename to docs/assets/configuration/configuration_assign_webservice_functions_3_thumb.png diff --git a/doc/configuration/configuration_assign_webservice_functions_4.png b/docs/assets/configuration/configuration_assign_webservice_functions_4.png similarity index 100% rename from doc/configuration/configuration_assign_webservice_functions_4.png rename to docs/assets/configuration/configuration_assign_webservice_functions_4.png diff --git a/doc/configuration/configuration_assign_webservice_functions_4_thumb.png b/docs/assets/configuration/configuration_assign_webservice_functions_4_thumb.png similarity index 100% rename from doc/configuration/configuration_assign_webservice_functions_4_thumb.png rename to docs/assets/configuration/configuration_assign_webservice_functions_4_thumb.png diff --git a/doc/configuration/configuration_create_moodle_user_1.png b/docs/assets/configuration/configuration_create_moodle_user_1.png similarity index 100% rename from doc/configuration/configuration_create_moodle_user_1.png rename to docs/assets/configuration/configuration_create_moodle_user_1.png diff --git a/doc/configuration/configuration_create_moodle_user_1_thumb.png b/docs/assets/configuration/configuration_create_moodle_user_1_thumb.png similarity index 100% rename from doc/configuration/configuration_create_moodle_user_1_thumb.png rename to docs/assets/configuration/configuration_create_moodle_user_1_thumb.png diff --git a/doc/configuration/configuration_create_moodle_user_2.png b/docs/assets/configuration/configuration_create_moodle_user_2.png similarity index 100% rename from doc/configuration/configuration_create_moodle_user_2.png rename to docs/assets/configuration/configuration_create_moodle_user_2.png diff --git a/doc/configuration/configuration_create_moodle_user_2_thumb.png b/docs/assets/configuration/configuration_create_moodle_user_2_thumb.png similarity index 100% rename from doc/configuration/configuration_create_moodle_user_2_thumb.png rename to docs/assets/configuration/configuration_create_moodle_user_2_thumb.png diff --git a/doc/configuration/configuration_create_role_1.png b/docs/assets/configuration/configuration_create_role_1.png similarity index 100% rename from doc/configuration/configuration_create_role_1.png rename to docs/assets/configuration/configuration_create_role_1.png diff --git a/doc/configuration/configuration_create_role_1_thumb.png b/docs/assets/configuration/configuration_create_role_1_thumb.png similarity index 100% rename from doc/configuration/configuration_create_role_1_thumb.png rename to docs/assets/configuration/configuration_create_role_1_thumb.png diff --git a/doc/configuration/configuration_create_role_2.png b/docs/assets/configuration/configuration_create_role_2.png similarity index 100% rename from doc/configuration/configuration_create_role_2.png rename to docs/assets/configuration/configuration_create_role_2.png diff --git a/doc/configuration/configuration_create_role_2_thumb.png b/docs/assets/configuration/configuration_create_role_2_thumb.png similarity index 100% rename from doc/configuration/configuration_create_role_2_thumb.png rename to docs/assets/configuration/configuration_create_role_2_thumb.png diff --git a/doc/configuration/configuration_create_role_3.png b/docs/assets/configuration/configuration_create_role_3.png similarity index 100% rename from doc/configuration/configuration_create_role_3.png rename to docs/assets/configuration/configuration_create_role_3.png diff --git a/doc/configuration/configuration_create_role_3_thumb.png b/docs/assets/configuration/configuration_create_role_3_thumb.png similarity index 100% rename from doc/configuration/configuration_create_role_3_thumb.png rename to docs/assets/configuration/configuration_create_role_3_thumb.png diff --git a/doc/configuration/configuration_create_role_4.png b/docs/assets/configuration/configuration_create_role_4.png similarity index 100% rename from doc/configuration/configuration_create_role_4.png rename to docs/assets/configuration/configuration_create_role_4.png diff --git a/doc/configuration/configuration_create_role_4_thumb.png b/docs/assets/configuration/configuration_create_role_4_thumb.png similarity index 100% rename from doc/configuration/configuration_create_role_4_thumb.png rename to docs/assets/configuration/configuration_create_role_4_thumb.png diff --git a/doc/configuration/configuration_create_webservice_1.png b/docs/assets/configuration/configuration_create_webservice_1.png similarity index 100% rename from doc/configuration/configuration_create_webservice_1.png rename to docs/assets/configuration/configuration_create_webservice_1.png diff --git a/doc/configuration/configuration_create_webservice_1_thumb.png b/docs/assets/configuration/configuration_create_webservice_1_thumb.png similarity index 100% rename from doc/configuration/configuration_create_webservice_1_thumb.png rename to docs/assets/configuration/configuration_create_webservice_1_thumb.png diff --git a/doc/configuration/configuration_create_webservice_2.png b/docs/assets/configuration/configuration_create_webservice_2.png similarity index 100% rename from doc/configuration/configuration_create_webservice_2.png rename to docs/assets/configuration/configuration_create_webservice_2.png diff --git a/doc/configuration/configuration_create_webservice_2_thumb.png b/docs/assets/configuration/configuration_create_webservice_2_thumb.png similarity index 100% rename from doc/configuration/configuration_create_webservice_2_thumb.png rename to docs/assets/configuration/configuration_create_webservice_2_thumb.png diff --git a/doc/configuration/configuration_create_webservice_3.png b/docs/assets/configuration/configuration_create_webservice_3.png similarity index 100% rename from doc/configuration/configuration_create_webservice_3.png rename to docs/assets/configuration/configuration_create_webservice_3.png diff --git a/doc/configuration/configuration_create_webservice_3_thumb.png b/docs/assets/configuration/configuration_create_webservice_3_thumb.png similarity index 100% rename from doc/configuration/configuration_create_webservice_3_thumb.png rename to docs/assets/configuration/configuration_create_webservice_3_thumb.png diff --git a/doc/configuration/configuration_enable_webservices_1.png b/docs/assets/configuration/configuration_enable_webservices_1.png similarity index 100% rename from doc/configuration/configuration_enable_webservices_1.png rename to docs/assets/configuration/configuration_enable_webservices_1.png diff --git a/doc/configuration/configuration_enable_webservices_1_thumb.png b/docs/assets/configuration/configuration_enable_webservices_1_thumb.png similarity index 100% rename from doc/configuration/configuration_enable_webservices_1_thumb.png rename to docs/assets/configuration/configuration_enable_webservices_1_thumb.png diff --git a/doc/configuration/configuration_enable_webservices_2.png b/docs/assets/configuration/configuration_enable_webservices_2.png similarity index 100% rename from doc/configuration/configuration_enable_webservices_2.png rename to docs/assets/configuration/configuration_enable_webservices_2.png diff --git a/doc/configuration/configuration_enable_webservices_2_thumb.png b/docs/assets/configuration/configuration_enable_webservices_2_thumb.png similarity index 100% rename from doc/configuration/configuration_enable_webservices_2_thumb.png rename to docs/assets/configuration/configuration_enable_webservices_2_thumb.png diff --git a/doc/configuration/configuration_enable_webservices_3.png b/docs/assets/configuration/configuration_enable_webservices_3.png similarity index 100% rename from doc/configuration/configuration_enable_webservices_3.png rename to docs/assets/configuration/configuration_enable_webservices_3.png diff --git a/doc/configuration/configuration_enable_webservices_3_thumb.png b/docs/assets/configuration/configuration_enable_webservices_3_thumb.png similarity index 100% rename from doc/configuration/configuration_enable_webservices_3_thumb.png rename to docs/assets/configuration/configuration_enable_webservices_3_thumb.png diff --git a/doc/configuration/configuration_enable_webservices_4.png b/docs/assets/configuration/configuration_enable_webservices_4.png similarity index 100% rename from doc/configuration/configuration_enable_webservices_4.png rename to docs/assets/configuration/configuration_enable_webservices_4.png diff --git a/doc/configuration/configuration_enable_webservices_4_thumb.png b/docs/assets/configuration/configuration_enable_webservices_4_thumb.png similarity index 100% rename from doc/configuration/configuration_enable_webservices_4_thumb.png rename to docs/assets/configuration/configuration_enable_webservices_4_thumb.png diff --git a/doc/configuration/configuration_job_autodelete.png b/docs/assets/configuration/configuration_job_autodelete.png similarity index 100% rename from doc/configuration/configuration_job_autodelete.png rename to docs/assets/configuration/configuration_job_autodelete.png diff --git a/doc/configuration/configuration_job_autodelete_thumb.png b/docs/assets/configuration/configuration_job_autodelete_thumb.png similarity index 100% rename from doc/configuration/configuration_job_autodelete_thumb.png rename to docs/assets/configuration/configuration_job_autodelete_thumb.png diff --git a/docs/assets/configuration/configuration_job_image_optimization.png b/docs/assets/configuration/configuration_job_image_optimization.png new file mode 100644 index 0000000..dd1db0a Binary files /dev/null and b/docs/assets/configuration/configuration_job_image_optimization.png differ diff --git a/docs/assets/configuration/configuration_job_image_optimization_thumb.png b/docs/assets/configuration/configuration_job_image_optimization_thumb.png new file mode 100644 index 0000000..4dc71c3 Binary files /dev/null and b/docs/assets/configuration/configuration_job_image_optimization_thumb.png differ diff --git a/docs/assets/configuration/configuration_plugin_autoinstall_2.png b/docs/assets/configuration/configuration_plugin_autoinstall_2.png new file mode 100644 index 0000000..9b14255 Binary files /dev/null and b/docs/assets/configuration/configuration_plugin_autoinstall_2.png differ diff --git a/docs/assets/configuration/configuration_plugin_autoinstall_2_thumb.png b/docs/assets/configuration/configuration_plugin_autoinstall_2_thumb.png new file mode 100644 index 0000000..7b6c8bc Binary files /dev/null and b/docs/assets/configuration/configuration_plugin_autoinstall_2_thumb.png differ diff --git a/docs/assets/configuration/configuration_plugin_autoinstall_3.png b/docs/assets/configuration/configuration_plugin_autoinstall_3.png new file mode 100644 index 0000000..0f38fe6 Binary files /dev/null and b/docs/assets/configuration/configuration_plugin_autoinstall_3.png differ diff --git a/docs/assets/configuration/configuration_plugin_autoinstall_3_thumb.png b/docs/assets/configuration/configuration_plugin_autoinstall_3_thumb.png new file mode 100644 index 0000000..e44489f Binary files /dev/null and b/docs/assets/configuration/configuration_plugin_autoinstall_3_thumb.png differ diff --git a/docs/assets/configuration/configuration_plugin_autoinstall_4.png b/docs/assets/configuration/configuration_plugin_autoinstall_4.png new file mode 100644 index 0000000..5308d9b Binary files /dev/null and b/docs/assets/configuration/configuration_plugin_autoinstall_4.png differ diff --git a/docs/assets/configuration/configuration_plugin_autoinstall_4_thumb.png b/docs/assets/configuration/configuration_plugin_autoinstall_4_thumb.png new file mode 100644 index 0000000..c449645 Binary files /dev/null and b/docs/assets/configuration/configuration_plugin_autoinstall_4_thumb.png differ diff --git a/docs/assets/configuration/configuration_plugin_autoinstall_workerurl.png b/docs/assets/configuration/configuration_plugin_autoinstall_workerurl.png new file mode 100644 index 0000000..09bc032 Binary files /dev/null and b/docs/assets/configuration/configuration_plugin_autoinstall_workerurl.png differ diff --git a/docs/assets/configuration/configuration_plugin_autoinstall_workerurl_thumb.png b/docs/assets/configuration/configuration_plugin_autoinstall_workerurl_thumb.png new file mode 100644 index 0000000..b77b9a3 Binary files /dev/null and b/docs/assets/configuration/configuration_plugin_autoinstall_workerurl_thumb.png differ diff --git a/doc/configuration/configuration_plugin_settings_1.png b/docs/assets/configuration/configuration_plugin_settings_1.png similarity index 100% rename from doc/configuration/configuration_plugin_settings_1.png rename to docs/assets/configuration/configuration_plugin_settings_1.png diff --git a/doc/configuration/configuration_plugin_settings_1_thumb.png b/docs/assets/configuration/configuration_plugin_settings_1_thumb.png similarity index 100% rename from doc/configuration/configuration_plugin_settings_1_thumb.png rename to docs/assets/configuration/configuration_plugin_settings_1_thumb.png diff --git a/doc/configuration/configuration_plugin_settings_2.png b/docs/assets/configuration/configuration_plugin_settings_2.png similarity index 100% rename from doc/configuration/configuration_plugin_settings_2.png rename to docs/assets/configuration/configuration_plugin_settings_2.png diff --git a/doc/configuration/configuration_plugin_settings_2_thumb.png b/docs/assets/configuration/configuration_plugin_settings_2_thumb.png similarity index 100% rename from doc/configuration/configuration_plugin_settings_2_thumb.png rename to docs/assets/configuration/configuration_plugin_settings_2_thumb.png diff --git a/docs/assets/configuration/configuration_quiz_archive_creation_1.png b/docs/assets/configuration/configuration_quiz_archive_creation_1.png new file mode 100644 index 0000000..99e79a4 Binary files /dev/null and b/docs/assets/configuration/configuration_quiz_archive_creation_1.png differ diff --git a/docs/assets/configuration/configuration_quiz_archive_creation_1_thumb.png b/docs/assets/configuration/configuration_quiz_archive_creation_1_thumb.png new file mode 100644 index 0000000..eda089d Binary files /dev/null and b/docs/assets/configuration/configuration_quiz_archive_creation_1_thumb.png differ diff --git a/docs/assets/configuration/configuration_quiz_archive_creation_2.png b/docs/assets/configuration/configuration_quiz_archive_creation_2.png new file mode 100644 index 0000000..48e0148 Binary files /dev/null and b/docs/assets/configuration/configuration_quiz_archive_creation_2.png differ diff --git a/docs/assets/configuration/configuration_quiz_archive_creation_2_thumb.png b/docs/assets/configuration/configuration_quiz_archive_creation_2_thumb.png new file mode 100644 index 0000000..3c5ef69 Binary files /dev/null and b/docs/assets/configuration/configuration_quiz_archive_creation_2_thumb.png differ diff --git a/docs/assets/configuration/configuration_quiz_archive_creation_3.png b/docs/assets/configuration/configuration_quiz_archive_creation_3.png new file mode 100644 index 0000000..566ab91 Binary files /dev/null and b/docs/assets/configuration/configuration_quiz_archive_creation_3.png differ diff --git a/docs/assets/configuration/configuration_quiz_archive_creation_3_thumb.png b/docs/assets/configuration/configuration_quiz_archive_creation_3_thumb.png new file mode 100644 index 0000000..1f322a0 Binary files /dev/null and b/docs/assets/configuration/configuration_quiz_archive_creation_3_thumb.png differ diff --git a/docs/assets/configuration/configuration_quiz_archive_creation_4.png b/docs/assets/configuration/configuration_quiz_archive_creation_4.png new file mode 100644 index 0000000..22c5b96 Binary files /dev/null and b/docs/assets/configuration/configuration_quiz_archive_creation_4.png differ diff --git a/docs/assets/configuration/configuration_quiz_archive_creation_4_thumb.png b/docs/assets/configuration/configuration_quiz_archive_creation_4_thumb.png new file mode 100644 index 0000000..879d0c1 Binary files /dev/null and b/docs/assets/configuration/configuration_quiz_archive_creation_4_thumb.png differ diff --git a/docs/assets/configuration/configuration_quiz_archive_delete_1.png b/docs/assets/configuration/configuration_quiz_archive_delete_1.png new file mode 100644 index 0000000..9d7a2c9 Binary files /dev/null and b/docs/assets/configuration/configuration_quiz_archive_delete_1.png differ diff --git a/docs/assets/configuration/configuration_quiz_archive_delete_1_thumb.png b/docs/assets/configuration/configuration_quiz_archive_delete_1_thumb.png new file mode 100644 index 0000000..19c75a9 Binary files /dev/null and b/docs/assets/configuration/configuration_quiz_archive_delete_1_thumb.png differ diff --git a/docs/assets/configuration/configuration_quiz_archive_download_1.png b/docs/assets/configuration/configuration_quiz_archive_download_1.png new file mode 100644 index 0000000..f5c88c8 Binary files /dev/null and b/docs/assets/configuration/configuration_quiz_archive_download_1.png differ diff --git a/docs/assets/configuration/configuration_quiz_archive_download_1_thumb.png b/docs/assets/configuration/configuration_quiz_archive_download_1_thumb.png new file mode 100644 index 0000000..5722188 Binary files /dev/null and b/docs/assets/configuration/configuration_quiz_archive_download_1_thumb.png differ diff --git a/docs/assets/configuration/configuration_quiz_archive_inspection_1.png b/docs/assets/configuration/configuration_quiz_archive_inspection_1.png new file mode 100644 index 0000000..7ccd794 Binary files /dev/null and b/docs/assets/configuration/configuration_quiz_archive_inspection_1.png differ diff --git a/docs/assets/configuration/configuration_quiz_archive_inspection_1_thumb.png b/docs/assets/configuration/configuration_quiz_archive_inspection_1_thumb.png new file mode 100644 index 0000000..d18e989 Binary files /dev/null and b/docs/assets/configuration/configuration_quiz_archive_inspection_1_thumb.png differ diff --git a/docs/assets/configuration/configuration_quiz_archive_inspection_2.png b/docs/assets/configuration/configuration_quiz_archive_inspection_2.png new file mode 100644 index 0000000..841ac51 Binary files /dev/null and b/docs/assets/configuration/configuration_quiz_archive_inspection_2.png differ diff --git a/docs/assets/configuration/configuration_quiz_archive_inspection_2_thumb.png b/docs/assets/configuration/configuration_quiz_archive_inspection_2_thumb.png new file mode 100644 index 0000000..23cd69d Binary files /dev/null and b/docs/assets/configuration/configuration_quiz_archive_inspection_2_thumb.png differ diff --git a/doc/configuration/configuration_tsp_settings_2.png b/docs/assets/configuration/configuration_tsp_settings_2.png similarity index 100% rename from doc/configuration/configuration_tsp_settings_2.png rename to docs/assets/configuration/configuration_tsp_settings_2.png diff --git a/doc/configuration/configuration_tsp_settings_2_thumb.png b/docs/assets/configuration/configuration_tsp_settings_2_thumb.png similarity index 100% rename from doc/configuration/configuration_tsp_settings_2_thumb.png rename to docs/assets/configuration/configuration_tsp_settings_2_thumb.png diff --git a/docs/assets/docs-button.png b/docs/assets/docs-button.png new file mode 100644 index 0000000..519614b Binary files /dev/null and b/docs/assets/docs-button.png differ diff --git a/doc/moodle-plugin-directory-button.png b/docs/assets/moodle-plugin-directory-button.png similarity index 100% rename from doc/moodle-plugin-directory-button.png rename to docs/assets/moodle-plugin-directory-button.png diff --git a/docs/assets/screenshots/quiz_archiver_demomode_watermark.png b/docs/assets/screenshots/quiz_archiver_demomode_watermark.png new file mode 100644 index 0000000..d37e4e2 Binary files /dev/null and b/docs/assets/screenshots/quiz_archiver_demomode_watermark.png differ diff --git a/doc/screenshots/quiz_archiver_job_details_modal.png b/docs/assets/screenshots/quiz_archiver_job_details_modal.png similarity index 100% rename from doc/screenshots/quiz_archiver_job_details_modal.png rename to docs/assets/screenshots/quiz_archiver_job_details_modal.png diff --git a/doc/screenshots/quiz_archiver_job_details_modal_autodelete.png b/docs/assets/screenshots/quiz_archiver_job_details_modal_autodelete.png similarity index 100% rename from doc/screenshots/quiz_archiver_job_details_modal_autodelete.png rename to docs/assets/screenshots/quiz_archiver_job_details_modal_autodelete.png diff --git a/doc/screenshots/quiz_archiver_job_details_modal_tsp_data.png b/docs/assets/screenshots/quiz_archiver_job_details_modal_tsp_data.png similarity index 100% rename from doc/screenshots/quiz_archiver_job_details_modal_tsp_data.png rename to docs/assets/screenshots/quiz_archiver_job_details_modal_tsp_data.png diff --git a/doc/screenshots/quiz_archiver_new_job_queued.png b/docs/assets/screenshots/quiz_archiver_new_job_queued.png similarity index 100% rename from doc/screenshots/quiz_archiver_new_job_queued.png rename to docs/assets/screenshots/quiz_archiver_new_job_queued.png diff --git a/doc/screenshots/quiz_archiver_overview_page.png b/docs/assets/screenshots/quiz_archiver_overview_page.png similarity index 100% rename from doc/screenshots/quiz_archiver_overview_page.png rename to docs/assets/screenshots/quiz_archiver_overview_page.png diff --git a/doc/screenshots/quiz_archiver_report_example_pdf_header.png b/docs/assets/screenshots/quiz_archiver_report_example_pdf_header.png similarity index 100% rename from doc/screenshots/quiz_archiver_report_example_pdf_header.png rename to docs/assets/screenshots/quiz_archiver_report_example_pdf_header.png diff --git a/doc/screenshots/quiz_archiver_report_example_pdf_question_1.png b/docs/assets/screenshots/quiz_archiver_report_example_pdf_question_1.png similarity index 100% rename from doc/screenshots/quiz_archiver_report_example_pdf_question_1.png rename to docs/assets/screenshots/quiz_archiver_report_example_pdf_question_1.png diff --git a/doc/screenshots/quiz_archiver_report_example_pdf_question_2.png b/docs/assets/screenshots/quiz_archiver_report_example_pdf_question_2.png similarity index 100% rename from doc/screenshots/quiz_archiver_report_example_pdf_question_2.png rename to docs/assets/screenshots/quiz_archiver_report_example_pdf_question_2.png diff --git a/doc/screenshots/quiz_archiver_report_example_pdf_question_3.png b/docs/assets/screenshots/quiz_archiver_report_example_pdf_question_3.png similarity index 100% rename from doc/screenshots/quiz_archiver_report_example_pdf_question_3.png rename to docs/assets/screenshots/quiz_archiver_report_example_pdf_question_3.png diff --git a/docs/bugreport.md b/docs/bugreport.md new file mode 100644 index 0000000..cf8fd55 --- /dev/null +++ b/docs/bugreport.md @@ -0,0 +1,9 @@ +# Reporting bugs and getting help + +If you have got a question, found a bug, or have a feature request, please check +out the [GitHub issue tracker](https://github.com/ngandrass/moodle-quiz_archiver/issues). + +[:simple-github: Issue Tracker](https://github.com/ngandrass/moodle-quiz_archiver/issues){ .md-button } + +You can search through existing discussions or create a new issue if you can't +find a solution to your problem. diff --git a/docs/changelog.md b/docs/changelog.md new file mode 120000 index 0000000..04c99a5 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1 @@ +../CHANGELOG.md \ No newline at end of file diff --git a/docs/configuration/capabilities.md b/docs/configuration/capabilities.md new file mode 100644 index 0000000..fd6f441 --- /dev/null +++ b/docs/configuration/capabilities.md @@ -0,0 +1,17 @@ +# Capabilities + +Moodle capabilities are used to define what a user can and cannot do within the +system. The Quiz Archiver plugin uses several custom capabilities to control +access to its features. + +The following capabilities are introduced by the plugin and required for the +listed actions: + +| Capability | Context | Default assignments | Description | +|------------------------------------|---------|----------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `mod/quiz_archiver:view` | Module | `teacher`, `editingteacher`, `manager` | Required to view the quiz archiver overview page. It allows to download all created archives but does not allow do create new or delete existing archives (read-only access). | +| `mod/quiz_archiver:create` | Module | `editingteacher`, `manager` | Allows creation of new quiz archives. | +| `mod/quiz_archiver:delete` | Module | `editingteacher`, `manager` | Allows deletion of existing quiz archives. | +| `mod/quiz_archiver:use_webservice` | System | *None* | Required to use any of this plugins webservice functions. The webservice user[^1] needs to have this capability in order to create new quiz archives. | + +[^1]: The webservice user is created during the [initial plugin configuration](/configuration). \ No newline at end of file diff --git a/docs/configuration/index.md b/docs/configuration/index.md new file mode 100644 index 0000000..5bb6378 --- /dev/null +++ b/docs/configuration/index.md @@ -0,0 +1,28 @@ +# Configuration + +This sections describes the required steps to initially set up the plugin. + +In summary: A dedicated Moodle user, a global role to manage permissions, and a +webservice for the archive worker must be created and the Moodle plugin must be +configured accordingly. Luckily, this can be done automatically using a single +button (see [Automatic Configuration](/configuration/initialconfig/automatic)). + +If you encounter any issues during the configuration process, please open a bug +report or ask a question in the issue tracker over on GitHub. + +[:simple-github: Issue Tracker](https://github.com/ngandrass/moodle-quiz_archiver/issues){ .md-button } + + +## Starting the Configuration + +!!! warning "Prerequisites" + Before you start the initial configuration, make sure you have the following + components successfully installed: + + - [Moodle Plugin](/installation/moodleplugin) + - [Archive Worker Service](/installation/archiveworker) (or using the + [free public demo service](/installation/archiveworker#using-the-free-public-demo-service)) + +After installation, you need to perform an [initial configuration step](/configuration/initialconfig/automatic) once. + +[:material-cog-play: Automatic Configuration](/configuration/initialconfig/automatic){ .md-button } diff --git a/docs/configuration/initialconfig/automatic.md b/docs/configuration/initialconfig/automatic.md new file mode 100644 index 0000000..26b6a77 --- /dev/null +++ b/docs/configuration/initialconfig/automatic.md @@ -0,0 +1,85 @@ +# Automatic Configuration + +Creation of the dedicated Moodle user and role, as well as the setup of the +webservice for the archive worker, can be done automatically. + +The easiest way is to use the automatic configuration feature provided via the +Moodle admin interface but a fully automated configuration via CLI is also +supported. + + +## Using the Moodle Admin Interface + +!!! info + This is the recommended way to configure the Quiz Archiver Moodle plugin for + most users. + +1. Navigate to _Site Administration_ > _Plugins_ (1) > _Activity modules_ > + _Quiz_ > _Quiz Archiver_ (2) +2. Click the _Automatic configuration_ button (3) +3. Enter the URL under which the quiz archive worker can be reached (4) +4. (Optional) Change the configuration defaults (5) +5. Execute the automatic configuration (6) +6. Close the window (7) +7. (Optional) Adjust the default plugin setting on the plugin settings page + +![Screenshot: Configuration - Automatic Configuration 1](/assets/configuration/configuration_plugin_settings_1.png){ .img-thumbnail } +![Screenshot: Configuration - Automatic Configuration 1](/assets/configuration/configuration_plugin_autoinstall_2.png){ .img-thumbnail } +![Screenshot: Configuration - Automatic Configuration 1](/assets/configuration/configuration_plugin_autoinstall_3.png){ .img-thumbnail } +![Screenshot: Configuration - Automatic Configuration 1](/assets/configuration/configuration_plugin_autoinstall_4.png){ .img-thumbnail } + + +## Using the Command Line Interface (CLI) + +!!! warning + This method is recommended for advanced users only. If you have used the + Moodle admin interface to configure the plugin, you can skip this step. + +If you want to configure this plugin in an automated fashion, you can use the +provided CLI script. The script is located at +`{$CFG->wwwroot}/mod/quiz/report/archiver/cli/autoinstall.php`. + +To execute the script: + +1. Open a terminal and navigate to the quiz archiver CLI directory: + ```text + cd /path/to/moodle/mod/quiz/report/archiver/cli + ``` +2. Execute the CLI script using PHP: + ```text + php autoinstall.php --help + ``` + +Usage: +```text +Automatically configures Moodle for use with the quiz archiver plugin. + +ATTENTION: This CLI script ... +- Enables web services and REST protocol +- Creates a quiz archiver service role and a corresponding user +- Creates a new web service with all required webservice functions +- Authorises the user to use the webservice. + +Usage: + $ php autoinstall.php + $ php autoinstall.php --username="my-custom-archive-user" + $ php autoinstall.php [--help|-h] + +Options: + --help, -h Show this help message + --force, -f Force the autoinstall, regardless of the current state of the system + --workerurl= Sets the URL of the worker (default: http://localhost:8080) + --wsname= Sets a custom name for the web service (default: quiz_archiver_webservice) + --rolename= Sets a custom name for the web service role (default: quiz_archiver) + --username= Sets a custom username for the web service user (default: quiz_archiver_serviceaccount) +``` + +## Next Steps + +You finished the initial configuration of the quiz archiver plugin. You now can +either directly start archiving quizzes (see [Usage](/usage)) or adjust the +default plugin settings (see [Job Presets / Policies](/configuration/presets)). + +[:material-account: Usage](/usage){ .md-button } +      +[:material-file-cog: Job Presets](/configuration/presets){ .md-button } diff --git a/docs/configuration/initialconfig/manual.md b/docs/configuration/initialconfig/manual.md new file mode 100644 index 0000000..0409ff5 --- /dev/null +++ b/docs/configuration/initialconfig/manual.md @@ -0,0 +1,144 @@ +# Manual Configuration + +This plugin requires the creation of a dedicated Moodle user and role, as well +as the setup of the Moodle webservices for the archive worker. + +!!! warning + This is the manual configuration process which can be quite involved. Please + only use the manual configuration if you consider yourself an advanced user + / Moodle administrator. + + [:material-cog-play: Automatic Configuration](/configuration/initialconfig/automatic){ .md-button } +     + Most users want to use the + [automated configuration](/configuration/initialconfig/automatic) instead. + +## Create Moodle User and Role + +At first, a new Moodle user and a global role need to be created for the Quiz +Archiver. It will be used by the archive worker service to access quiz data. + +### Create a designated Moodle user for the quiz archiver webservice + +1. Navigate to _Site Administration_ > _Users_ (1) > _Accounts_ > _Add a new user_ (2) +2. Set a username (e.g. `quiz_archiver`) (3), a password (4), first and + lastname (5), and a hidden email address (6) +3. Create the user (7) + +![Screenshot: Configuration - Create Moodle User 1](/assets/configuration/configuration_create_moodle_user_1.png){ .img-thumbnail } +![Screenshot: Configuration - Create Moodle User 2](/assets/configuration/configuration_create_moodle_user_2.png){ .img-thumbnail } + +### Create a global role to handle permissions for the `quiz_archiver` Moodle user + +1. Navigate to _Site Administration_ > _Users_ (1) > _Permissions_ > _Define roles_ (2) +2. Select _Add a new role_ (3) +3. Set _Use role or archetype_ (4) to `No role` +4. Upload the role definitions file from `res/moodle_role_quiz_archiver.xml` (5). + This will automatically assign all required capabilities[^1]. +5. Click on _Continue_ (6) to import the role definitions for review +6. Optionally change the role name or description and create the role (7) + +![Screenshot: Configuration - Create Role 1](/assets/configuration/configuration_create_role_1.png){ .img-thumbnail } +![Screenshot: Configuration - Create Role 2](/assets/configuration/configuration_create_role_2.png){ .img-thumbnail } +![Screenshot: Configuration - Create Role 3](/assets/configuration/configuration_create_role_3.png){ .img-thumbnail } +![Screenshot: Configuration - Create Role 4](/assets/configuration/configuration_create_role_4.png){ .img-thumbnail } + +[^1]: You can check all capabilities prior to role creation in the next step or +by manually inspecting the role definition XML file +(`res/moodle_role_quiz_archiver.xml`). + +### Assign the `quiz_archiver` Moodle user to the created role + +1. Navigate to _Site Administration_ > _Users_ (1) > _Permissions_ > _Assign system roles_ (2) +2. Select the `Quiz Archiver Service Account` role (3) +3. Search the created `quiz_archiver` Moodle user (4), select it in the list + of potential users (5), and add it to the role (6) + +![Screenshot: Configuration - Assign Role 1](/assets/configuration/configuration_assign_role_1.png){ .img-thumbnail } +![Screenshot: Configuration - Assign Role 2](/assets/configuration/configuration_assign_role_2.png){ .img-thumbnail } +![Screenshot: Configuration - Assign Role 3](/assets/configuration/configuration_assign_role_3.png){ .img-thumbnail } + + +## Setup Webservice + +The quiz archive worker service interacts with the Moodle platform using the +Moodle webservice API. Therefore, it must be enabled and a corresponding +external service must be created. + +### Enable webservices globally + +1. Navigate to _Site Administration_ > _Server_ (1) > _Web services_ > _Overview_ (2) +2. Click on _Enable web services_ (3), check the checkbox (4), and save the + changes (5) +3. Navigate back to the _Overview_ (2) page +4. Click on _Enable protocols_ (6), enable the _REST protocol_ (7), and save the + changes (8) + +![Screenshot: Configuration - Enable Webservices 1](/assets/configuration/configuration_enable_webservices_1.png){ .img-thumbnail } +![Screenshot: Configuration - Enable Webservices 2](/assets/configuration/configuration_enable_webservices_2.png){ .img-thumbnail } +![Screenshot: Configuration - Enable Webservices 3](/assets/configuration/configuration_enable_webservices_3.png){ .img-thumbnail } +![Screenshot: Configuration - Enable Webservices 4](/assets/configuration/configuration_enable_webservices_4.png){ .img-thumbnail } + +## Create an external webservice for the quiz archive worker to use + +1. Navigate to _Site Administration_ > _Server_ (1) > _Web services_ > _External services_ (2) +2. Under the _Custom services_ section, select _Add_ (3) +3. Enter a name (e.g. `quiz_archiver`) (4) and enable it (5) +4. Expand the additional settings (6), enable file up- and download (7) +5. Create the new webservice by clicking _Add service_ (8) + +![Screenshot: Configuration - Create Webservice 1](/assets/configuration/configuration_create_webservice_1.png){ .img-thumbnail } +![Screenshot: Configuration - Create Webservice 2](/assets/configuration/configuration_create_webservice_2.png){ .img-thumbnail } +![Screenshot: Configuration - Create Webservice 3](/assets/configuration/configuration_create_webservice_3.png){ .img-thumbnail } + +### Add all `quiz_archiver_*` webservice functions to the `quiz_archiver` external service + +1. Navigate to _Site Administration_ > _Server_ (1) > _Web services_ > _External services_ (2) +2. Open the _Functions_ page for the `quiz_archiver` webservice (3) +3. Click the _Add functions_ link (4) +4. Search for `quiz_archiver` (5) and add all `quiz_archiver_*` functions +5. Save the changes by clicking _Add functions_ (6) + +![Screenshot: Configuration - Assign Webservice Functions 1](/assets/configuration/configuration_assign_webservice_functions_1.png){ .img-thumbnail } +![Screenshot: Configuration - Assign Webservice Functions 2](/assets/configuration/configuration_assign_webservice_functions_2.png){ .img-thumbnail } +![Screenshot: Configuration - Assign Webservice Functions 3](/assets/configuration/configuration_assign_webservice_functions_3.png){ .img-thumbnail } +![Screenshot: Configuration - Assign Webservice Functions 4](/assets/configuration/configuration_assign_webservice_functions_4.png){ .img-thumbnail } + + +## Configure Plugin Settings + +Once the user, role, and webservice are created, the last step is to configure +the quiz archiver plugin to use the created webservice and user. + +1. Navigate to _Site Administration_ > _Plugins_ (1) > _Activity modules_ > + _Quiz_ > _Quiz Archiver_ (2) +2. Set `worker_url` (3) to the URL under which the quiz archive worker can be + reached (e.g., `http://quiz-archive-worker:5000` or `http://127.0.0.1:5000`) +3. Select the previously created `quiz_archiver` webservice for `webservice_id` (4) + from the drop-down menu +4. Enter the user ID of the previously created Moodle user for `webservice_userid` (5). + It can easily be found by navigating to the users profile page and inspecting + the page URL. It contains the user ID as the `id` query parameter. +5. (Optional) Specify a custom job timeout in minutes +6. (Optional) Specify a custom Moodle base URL[^2]. +7. Save all settings and create your first quiz archive (see [Usage](#usage)). +8. (Optional) Adjust the default [capability](#capabilities) assignments. + +![Screenshot: Configuration - Plugin Settings 1](/assets/configuration/configuration_plugin_settings_1.png){ .img-thumbnail } +![Screenshot: Configuration - Plugin Settings 2](/assets/configuration/configuration_plugin_settings_2.png){ .img-thumbnail } + +[^2]: This is only required if you run the quiz archive worker in an internal / +private network, e.g., when using Docker. If this setting is present, the public +Moodle `$CFG->wwwroot` will be replaced by the `internal_wwwroot` setting. +Example: `https://your.public.moodle/` will be replaced by `http://moodle.local/`. + + +## Next Steps + +You finished the initial configuration of the quiz archiver plugin. You now can +either directly start archiving quizzes (see [Usage](/usage)) or adjust the +default plugin settings (see [Job Presets / Policies](/configuration/presets)). + +[:material-account: Usage](/usage){ .md-button } +      +[:material-file-cog: Job Presets](/configuration/presets){ .md-button } diff --git a/docs/configuration/initialconfig/pitfalls.md b/docs/configuration/initialconfig/pitfalls.md new file mode 100644 index 0000000..d40c782 --- /dev/null +++ b/docs/configuration/initialconfig/pitfalls.md @@ -0,0 +1,99 @@ +# Known Pitfalls + +This section lists some common pitfalls that you might encounter when setting +up or using the quiz archiver plugin. + + +## A job timeouts prior to the configured timeout value + +Be aware that there is a configurable job timeout within the Moodle plugin +settings (`quiz_archiver | job_timeout_min`) as well as one within the quiz +archive worker service (`QUIZ_ARCHIVER_REQUEST_TIMEOUT_SEC`). + +!!! warning + Since the shortest timeout always takes precedence, make sure to adjust + **both** timeout settings as required. + + +## The archive worker service cannot access the Moodle instance + +If the archive worker service can not access your Moodle instance, you should +check the following: + +1. Ensure that the archive worker service is able to resolve the hostname of your + Moodle instance. + - You can test this by running `nslookup yourmoodle.tld` on the machine that + runs the archive worker service. + - If the lookup fails, check the DNS settings on the machine that runs the + archive worker service. +2. Ensure that the archive worker service is able to reach the machine that runs + your Moodle instance. + - You can test this by running `ping yourmoodle.tld` on the machine that + runs the archive worker service. + - If the ping fails, check the network and firewall settings on both + machines. +3. Ensure that the archive worker service is able to retrieve a basic web page + from your Moodle instance. + - You can test this by running `curl -v https://yourmoodle.tld` on the + machine that runs the archive worker service. + - If the curl command fails, check the firewall settings on both machines. + + +## Access to Moodle webservice functions fails + +If you get an error message that access to one or more webservice functions is +denied, you should check the following: + +1. Ensure that your archive worker service is able to [connect to your Moodle + instance](#the-archive-worker-service-cannot-access-the-moodle-instance). +2. Ensure that webservices and the REST protocol are enabled globally. +3. Ensure that all required webservice functions are enabled for the + `quiz_archiver` webservice. +4. Ensure that the `quiz_archiver` webservice has the rights to download and + upload files. +5. Ensure that the `quiz_archiver` webservice user has accepted all site + policies (e.g., privacy policy). + + +## Upload of the quiz archive fails + +If the archive worker is able to create the quiz archive but fails to upload it +back to your Moodle, you should check the following: + +1. Ensure you have configured PHP to accept large file uploads. The + `upload_max_filesize` and `post_max_size` settings in your `php.ini` should + be set to a value that is large enough to allow the upload of the largest + quiz archive file that you expect to be created. Setting it to `512MB` is a + good starting point. +2. Ensure that your Moodle is configured to allow large file uploads. + `$CFG->maxbytes` should be set to the same value as PHP `upload_max_filesize`. +3. If you are using an ingress webserver and PHP-FPM via FastCGI, ensure that the + `fastcgi_send_timeout` and `fastcgi_read_timeout` settings are long enough to + allow the upload of the largest quiz archive file that you expect. + Nginx usually signals this problem by returning a '504 Gateway Time-out' + after 60 seconds (default). +4. Ensure that your antivirus plugin is capable of handling large files. When + using ClamAV you can control maximum file sizes by setting `MaxFileSize`, + `MaxScanSize`, and `StreamMaxLength` (when using a TCP socket) inside + `clamd.conf`. + + +## Text is not rendered correctly + +If the text in the generated PDF files is not rendered correctly, e.g., when +only rectangles are displayed instead of characters, please make sure that an +extended set of base fonts is available on your server. + +If you are running Ubuntu or Debian, you can ensure this by installing the +`fonts-noto` package via your package manager. + +If you are using the official Docker image, please open a bug report instead. + + +## Checking the plugin config + +If you are unsure whether there is a problem with your plugin configuration, you +can check the [manual configuration instructions](/configuration/initialconfig/manual) +and compare your local config against it. + +[:material-wrench-cog: Manual Configuration](/configuration/initialconfig/automatic){ .md-button } diff --git a/docs/configuration/presets.md b/docs/configuration/presets.md new file mode 100644 index 0000000..68f792c --- /dev/null +++ b/docs/configuration/presets.md @@ -0,0 +1,29 @@ +# Archive Job Presets (Global Defaults) + +Default values for all archive job settings can be configured globally on the +plugin settings page. + +!!! tip + By default, users are allowed to customize these settings during archive + creation. However, each setting can be locked individually to prevent users from + modifying it during archive creation. This allows the enforcement of + organization wide policies for archived quizzes. + +To customize these options: + +1. Navigate to _Site Administration_ > _Plugins_ (1) > _Activity modules_ > + _Quiz_ > _Quiz Archiver_ (2) +2. Scroll down to the _Archive presets_ section (3) +3. Set the desired default values for each option (4) + - Options can depend on another, as indicated by (6). This causes the + dependent option to be disabled, if the parent option is not set (e.g., + question feedback is not exported if question exporting is fully disabled) + - More options than shown in the screenshots are available. Scroll down to + see all (7) +4. (Optional) Lock individual options by checking the _Lock_ checkbox (5) + +Locked options will be grayed out during archive creation (8). + +![Screenshot: Configuration - Archive job presets 1](/assets/configuration/configuration_plugin_settings_1.png){ .img-thumbnail } +![Screenshot: Configuration - Archive job presets 2](/assets/configuration/configuration_archive_job_presets_2.png){ .img-thumbnail } +![Screenshot: Configuration - Archive job presets 3](/assets/configuration/configuration_archive_job_presets_3.png){ .img-thumbnail } diff --git a/docs/css/extra.css b/docs/css/extra.css new file mode 100644 index 0000000..742d9c2 --- /dev/null +++ b/docs/css/extra.css @@ -0,0 +1,24 @@ +.md-main { + margin-bottom: 48px; +} + +.img-thumbnail { + margin-right: 6px; + border: 2px solid #dddddd; + max-width: 320px !important; + max-height: 128px !important; + + box-shadow: 2px 2px 4px 0px rgba(0,0,0,0.06); + -webkit-box-shadow: 2px 2px 4px 0px rgba(0,0,0,0.06); + -moz-box-shadow: 2px 2px 4px 0px rgba(0,0,0,0.06); + + transition: all 0.15s ease-out; +} + +.img-thumbnail:hover, .img-thumbnail:active, .img-thumbnail:focus { + border: 2px solid #999999; + + box-shadow: 2px 2px 4px 0px rgba(0,0,0,0.1); + -webkit-box-shadow: 2px 2px 4px 0px rgba(0,0,0,0.1); + -moz-box-shadow: 2px 2px 4px 0px rgba(0,0,0,0.1); +} diff --git a/docs/development/codecoverage.md b/docs/development/codecoverage.md new file mode 100644 index 0000000..8acbed3 --- /dev/null +++ b/docs/development/codecoverage.md @@ -0,0 +1,30 @@ +# Code Coverage + +!!! warning + This page is primarily targeted at developers. If you are a normal user of + this plugin, you can safely skip this section. + +## Prerequisites + +To generate code coverage reports, you need to have: + +1. your [PHPUnit test environment](/development/unittests) set up. +2. the `xdebug` extension installed and enabled in your PHP environment. + + +## Generating Coverage Reports + +To generate code coverage reports, follow these steps: + +1. Run PHPUnit with coverage report: + ```text + XDEBUG_MODE=coverage vendor/bin/phpunit --colors --testdox --coverage-html /tmp/coverage --filter quiz_archiver/* + ``` +2. Copy the generated report to your machin: + ```text + docker cp my-moodle-container:/tmp/coverage /tmp/coverage + ``` +3. Open the report in your browser: + ```text + xdg-open /tmp/coverage/index.html + ``` diff --git a/docs/development/index.md b/docs/development/index.md new file mode 100644 index 0000000..1318bbf --- /dev/null +++ b/docs/development/index.md @@ -0,0 +1,7 @@ +# Development + +!!! info + If you are a normal user of this plugin, you can safely skip this section. + +This section is primarily aimed at developers who want to contribute to the quiz +archiver plugin or the quiz archive worker service. diff --git a/docs/development/testdata.md b/docs/development/testdata.md new file mode 100644 index 0000000..cc988db --- /dev/null +++ b/docs/development/testdata.md @@ -0,0 +1,20 @@ +# Reference Course / Test Data + +!!! warning + This page is primarily targeted at developers. If you are a normal user of + this plugin, you can safely skip this section. + +For testing purposes, a Moodle course that contains a reference quiz is provided. + +The quiz features an instance of every standard question type that is part of +the Moodle core. Example students are enrolled and graded quiz attempts have +been made, ready to test the archive functionality. + +The reference course moreover contains further quizzes with different numbers of +attempts to test the throughput of the quiz archive worker service. + + +## Importing the Reference Course + +You can import the reference course from the corresponding Moodle backup file +located at `res/backup-moodle2-course-qa-ref.mbz`. diff --git a/docs/development/unittests.md b/docs/development/unittests.md new file mode 100644 index 0000000..79feeb3 --- /dev/null +++ b/docs/development/unittests.md @@ -0,0 +1,64 @@ +# PHPUnit tests + +!!! warning + This page is primarily targeted at developers. If you are a normal user of + this plugin, you can safely skip this section. + + +## Creating a PHPUnit Test Environment + +1. Spawn a shell inside your Moodle / php-fpm container and navigate to your + Moodle root directory: + ```text + docker exec -it my-moodle-container sh + cd /usr/share/nginx/www/moodle/ + ``` +2. Prepare the Moodle PHPUnit configuration. Add the following lines to your + `config.php`: + ```php title="config.php" + phpunit_prefix = 'phpu_'; + $CFG->phpunit_dataroot = '/path/to/your/phpunit_moodledata'; + ``` +3. Download [composer](https://getcomposer.org/) and install dev dependencies: + ```text + wget https://getcomposer.org/download/latest-stable/composer.phar + php composer.phar install + ``` +4. Bootstrap the test environment: + ```text + php admin/tool/phpunit/cli/init.php --disable-composer + ``` + +See: [https://moodledev.io/general/development/tools/phpunit](https://moodledev.io/general/development/tools/phpunit) + + +## Running tests + +After you have sucessfully [created a PHPUnit envirnoment](#creating-a-phpunit-test-environment), +you can run the tests using the following commands: + +- Running all tests: + ```text + vendor/bin/phpunit --colors --testdox + ``` +- Running all tests for a single component: + ```text + vendor/bin/phpunit --colors --testdox --filter quiz_archiver/* + ``` +- Running a single test suite: + ```text + vendor/bin/phpunit --colors --testdox mod/quiz/report/archiver/tests/report_test.php + ``` + +!!! warning + All commands must be run from inside your Moodle root directory. + + +## Automatic Test Execution for All Supported Software Configurations + +The configuration for automated test execution via GitHub CI can be found in +`.github/workflows/moodle-plugin-ci.yml`. It holds a matrix of all supported +software configurations and runs the tests for each of them. + +See also: [Installation Requirements](/installation#requirements) diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..5b924e6 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,82 @@ +# Moodle Quiz Archiver + +[![Latest Version](https://img.shields.io/github/v/release/ngandrass/moodle-quiz_archiver)](https://github.com/ngandrass/moodle-quiz_archiver/releases) +[![Maintenance Status](https://img.shields.io/maintenance/yes/9999)](https://github.com/ngandrass/moodle-quiz_archiver/) +[![License](https://img.shields.io/github/license/ngandrass/moodle-quiz_archiver)](https://github.com/ngandrass/moodle-quiz_archiver/blob/master/LICENSE) +[![GitHub Issues](https://img.shields.io/github/issues/ngandrass/moodle-quiz_archiver)](https://github.com/ngandrass/moodle-quiz_archiver/issues) +[![GitHub Pull Requests](https://img.shields.io/github/issues-pr/ngandrass/moodle-quiz_archiver)](https://github.com/ngandrass/moodle-quiz_archiver/pulls) +[![Donate with PayPal](https://img.shields.io/badge/PayPal-donate-orange)](https://www.paypal.me/ngandrass) +[![Sponsor with GitHub](https://img.shields.io/badge/GitHub-sponsor-orange)](https://github.com/sponsors/ngandrass) +[![GitHub Stars](https://img.shields.io/github/stars/ngandrass/moodle-quiz_archiver?style=social)](https://github.com/ngandrass/moodle-quiz_archiver/stargazers) +[![GitHub Forks](https://img.shields.io/github/forks/ngandrass/moodle-quiz_archiver?style=social)](https://github.com/ngandrass/moodle-quiz_archiver/network/members) +[![GitHub Contributors](https://img.shields.io/github/contributors/ngandrass/moodle-quiz_archiver?style=social)](https://github.com/ngandrass/moodle-quiz_archiver/graphs/contributors) + +This plugin creates archivable versions of quiz attempts as PDF and HTML files +for long-term storage independent of Moodle. If desired, Moodle backups (`.mbz`) +of both the quiz and the whole course can be included. A checksum is calculated +for every file within the archive, as well as the archive itself, to allow +verification of file integrity. Archives can optionally be cryptographically +signed by a trusted authority using the [Time-Stamp Protocol (TSP)](https://en.wikipedia.org/wiki/Time_stamp_protocol). +Comprehensive archive settings allow selecting what should be included in the +generated reports on a fine-granular level (e.g., exclude example solutions, +include answer history, ...). + +Generated quiz attempt reports include all elements of the test, even complex +ones like [MathJax](https://www.mathjax.org/) formulas, [STACK](https://moodle.org/plugins/qtype_stack) +plots, [GeoGebra](https://www.geogebra.org/) applets, and other question / +content types that require JavaScript processing. All PDF and HTML files are +fully text-searchable, including rendered MathJax formulas. Content is saved +vector based, whenever possible, to allow high-quality printing and zooming +while keeping the file size down. + +Quiz archives are created by an external [quiz archive worker](https://github.com/ngandrass/moodle-quiz-archive-worker) +service to remove load from Moodle and to eliminate the need to install a large +number of software dependencies on the webserver. It can easily be deployed +using Docker. + + +## Getting Started + +Use the following buttons to jump to the desired section: + +[:material-download: Installation](/installation){ .md-button } +      +[:material-cog: Configuration](/configuration){ .md-button } +      +[:material-account: Usage](/usage){ .md-button } + + +## Features + +- Archiving of quiz attempts as PDF and HTML files +- Support for file submissions / attachments (e.g., essay files) +- Quiz attempt reports are accessible completely independent of Moodle, hereby + ensuring long-term readability +- Customization of generated PDF and HTML reports + - Allows creation of reduced reports, e.g., without example solutions, for + handing out to students during inspection +- Support for complex content and question types, including Drag and Drop, MathJax + formulas, STACK plots, and other question / content types that require JavaScript + processing +- Quiz attempt reports are fully text-searchable, including mathematical formulas +- Moodle backups (`.mbz`) of both the quiz and the whole course are supported +- Generation of checksums for every file within the archive and the archive itself +- Cryptographic signing of archives and their creation date using the [Time-Stamp Protocol (TSP)](https://en.wikipedia.org/wiki/Time_stamp_protocol) +- Archive and attempt report names are fully customizable and support dynamic + variables (e.g., course name, quiz name, username, ...) +- Fine granular permission / capability management (e.g., only allow archive + creation but prevent deletion) +- Allows definition of global archiving defaults as well as forced archiving + policies (i.e., locked archive job presets that cannot be changed by the user) +- Fully asynchronous archive creation to reduce load on Moodle Server +- Automatic deletion of quiz archives after a specified retention period +- Data compression and vector based MathJax formulas to preserve disk space +- Technical separation of Moodle and archive worker service +- Data-minimising and security driven design + +
    +[:simple-moodle: Moodle Plugin Directory](https://moodle.org/plugins/quiz_archiver){ .md-button } +      +[:simple-github: GitHub](https://github.com/ngandrass/moodle-quiz_archiver){ .md-button } +      +[:fontawesome-solid-bug: Bug Tracker](https://github.com/ngandrass/moodle-quiz_archiver/issues){ .md-button } diff --git a/docs/installation/archiveworker.md b/docs/installation/archiveworker.md new file mode 100644 index 0000000..de7d396 --- /dev/null +++ b/docs/installation/archiveworker.md @@ -0,0 +1,210 @@ +# Quiz Archive Worker Service + +This section describes the installation of the quiz archive worker service, +that works in conjunction with the [quiz_archiver](https://github.com/ngandrass/moodle-quiz_archiver) +Moodle plugin. It can be installed using multiple ways, though using +[Docker Compose](#installation-using-docker-compose) is recommended. + +The quiz archive worker service processes quiz archive jobs in the background. +It renders Moodle quiz attempts into PDF files, collects Moodle backups, +generates checksums, and packs the final quiz archives before it uploads it back +the Moodle instance. + +## Using the Free Public Demo Service + +If you want to try the Quiz Archiver without setting up your own quiz archive +service worker, you can use the free public demo worker. + +!!! notice + The public archive worker service is running in demo mode. + This means that a _DEMO MODE_ watermark will be added to all generated PDFs + (see screenshot below), only a limited number of attempts will be exported + per archive job, and only placeholder Moodle backups are included. + + Setting up your own quiz archive worker service removes these limitations. + See below for setup instructions. + +!!! warning + The public archive worker service must be able to access your Moodle + instance via the internet to work. Local and private Moodle instances + will not work with the demo worker. + +To use the free public demo worker, you can skip the installation for now and +directly proceed to the [configuration section](/configuration). Make sure to +specify the following _Archive worker URL_ (1) during configuration: + +```text title="Archive worker URL" +https://demoworker.quizarchiver.gandrass.de +``` + +![Screenshot: Automatic Configuration Archive Worker URL](/assets/configuration/configuration_plugin_autoinstall_workerurl.png){ .img-thumbnail } +![Screenshot: Demo mode watermark in attempt PDF](/assets/screenshots/quiz_archiver_demomode_watermark.png){ .img-thumbnail } + +[:material-cog: Configuration](/configuration){ .md-button } + + +## Installation using Docker Compose + +!!! success "Info" + This is the suggested way of installing the quiz archive worker service :thumbsup: + +1. Install [Docker](https://www.docker.com/) and [Docker Compose](https://docs.docker.com/compose/) +2. Create a `docker-compose.yml` inside a `moodle-quiz-archive-worker` folder + with the following content: + ```yaml title="docker-compose.yml" + services: + moodle-quiz-archive-worker: + image: ngandrass/moodle-quiz-archive-worker:latest + container_name: moodle-quiz-archive-worker + restart: always + ports: + - "8080:8080/tcp" + environment: + - QUIZ_ARCHIVER_LOG_LEVEL=INFO + ``` +3. From inside the `moodle-quiz-archive-worker` folder, run the application: + ```text + docker compose up + ``` + +!!! info "Changing the service port" + You can change the port that the quiz archive worker service is exposed on + the Docker host by replacing the first port number in the `ports` argument + within the `docker-compose.yml` file. + + ```yaml title="Example: Expose the service on port 4242" + ports: + - "4242:8080/tcp" + ``` + +!!! info "Changing configuration values" + You can change all [configuration values](#configuration) by setting the + respective environment variables inside `docker-compose.yml`. For more + details and all available configuration parameters see [Configuration](#configuration). + + ```yaml title="Example: Set the log level to DEBUG" + environment: + - QUIZ_ARCHIVER_LOG_LEVEL=DEBUG + ``` + +### Running the application in the background + +To run the application in the background, append the `-d` argument to your +command: + +```text +docker compose up -d +``` + +### Removing the application + +To remove all created containers, networks and volumes, run the following +command from inside the `moodle-quiz-archive-worker` folder: + +```text +docker compose down +``` + +## Installation using Docker + +!!! info + This is an alternative way of installing the quiz archive worker service + using Docker directly. + +1. Install [Docker](https://www.docker.com/) +2. Run a new container: + ```text + docker run -p 8080:8080 ngandrass/moodle-quiz-archive-worker:latest + ``` + +!!! info "Changing the service port" + You can change the host port the application is bound to by changing the + first port number in the `-p` argument of the `docker run` command. + + ```text title="Example: Expose the service on port 4242" + docker run -p 4242:8080 moodle-quiz-archive-worker:latest + ``` + +!!! info "Changing configuration values" + You can change all [configuration values](#configuration) by setting the + respective environment variables. For more details and all available + configuration parameters see [Configuration](#configuration). + + ```text title="Example: Set the log level to DEBUG" + docker run -e QUIZ_ARCHIVER_LOG_LEVEL=DEBUG -p 8080:8080 moodle-quiz-archive-worker:latest + ``` + + +### Building the image locally + +You can also build the Docker image locally by conducting the following steps: + +1. Install [Docker](https://www.docker.com/) +2. Clone the Git repository: `git clone https://github.com/ngandrass/moodle-quiz-archive-worker` +3. Switch into the repository directory: `cd moodle-quiz-archive-worker` +4. Build the Docker image: `docker build -t moodle-quiz-archive-worker:latest .`[^1] +5. Run a container: `docker run -p 8080:8080 moodle-quiz-archive-worker:latest` + +[^1]: The `.` at the end of the `docker build` command **must** be part of the +command. It specifies the current directory as the build context. + +## Manual Installation + +!!! warning + This is the most complex way of installing the quiz archive worker service. + Please try to use a Docker based installation if possible. + +1. Install [Python](https://www.python.org/) version >= 3.11 +2. Install [Poetry](https://python-poetry.org/): `pip install poetry` +3. Clone the Git repository: `git clone https://github.com/ngandrass/moodle-quiz-archive-worker` +4. Switch into the repository directory: `cd moodle-quiz-archive-worker` +5. Install app dependencies: `poetry install` +6. Download [Playwright](https://playwright.dev/) browser binaries: `poetry run python -m playwright install chromium` +7. Run the application: `poetry run python main.py` + +!!! info "Changing configuration values" + You can change configuration values by prepending the respective environment + variables. For more details and all available configuration parameters see + [Configuration](#configuration). + + ```text title="Example: Set the service port to 4242" + QUIZ_ARCHIVER_SERVER_PORT=4242 poetry run python moodle-quiz-archive-worker.py + ``` + + +## Configuration + +Configuration parameters are located inside `config.py` and can be overwritten +using the following environment variables: + +| Environment Variable | Default Value | Description | +|-----------------------------------------------------------------|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `QUIZ_ARCHIVER_SERVER_HOST` | `0.0.0.0` | Host to bind to | +| `QUIZ_ARCHIVER_SERVER_PORT` | `8080` | Port to bind to | +| `QUIZ_ARCHIVER_LOG_LEVEL` | `INFO` | Logging level. One of the following:
    `'CRITICAL'`, `'FATAL'`, `'ERROR'`, `'WARN'`, `'WARNING'`, `'INFO'`, `'DEBUG'` | +| `QUIZ_ARCHIVER_QUEUE_SIZE` | `8` | Maximum number of jobs to enqueue | +| `QUIZ_ARCHIVER_HISTORY_SIZE` | `128` | Maximum number of jobs to remember in job history | +| `QUIZ_ARCHIVER_STATUS_REPORTING_INTERVAL_SEC` | `15` | Number of seconds to wait between job progress updates | +| `QUIZ_ARCHIVER_REQUEST_TIMEOUT_SEC` | `3600` | Maximum number of seconds a single job is allowed to run before it is terminated | +| `QUIZ_ARCHIVER_BACKUP_STATUS_RETRY_SEC` | `30` | Number of seconds to wait between backup status queries | +| `QUIZ_ARCHIVER_DOWNLOAD_MAX_FILESIZE_BYTES` | `(1024 * 10e6)` | Maximum number of bytes a generic Moodle file is allowed to have for downloading | +| `QUIZ_ARCHIVER_BACKUP_DOWNLOAD_MAX_FILESIZE_BYTES` | `(512 * 10e6)` | Maximum number of bytes Moodle backups are allowed to have | +| `QUIZ_ARCHIVER_QUESTION_ATTACHMENT_DOWNLOAD_MAX_FILESIZE_BYTES` | `(128 * 10e6)` | Maximum number of bytes a question attachment is allowed to have for downloading | +| `QUIZ_ARCHIVER_REPORT_BASE_VIEWPORT_WIDTH` | `1240` | Width of the viewport on attempt rendering in px | +| `QUIZ_ARCHIVER_REPORT_PAGE_MARGIN` | `'5mm'` | Margin (top, bottom, left, right) of the report PDF pages including unit (mm, cm, in, px) | +| `QUIZ_ARCHIVER_WAIT_FOR_READY_SIGNAL` | `True` | Whether to wait for the ready signal from the report page JS before generating the export | +| `QUIZ_ARCHIVER_WAIT_FOR_READY_SIGNAL_TIMEOUT_SEC` | `30` | Number of seconds to wait for the ready signal from the report page JS before generating the export | +| `QUIZ_ARCHIVER_CONTINUE_AFTER_READY_SIGNAL_TIMEOUT` | `False` | Whether to continue with the export if the ready signal was not received in time | +| `QUIZ_ARCHIVER_WAIT_FOR_NAVIGATION_TIMEOUT_SEC` | `30` | Number of seconds to wait for the report page to load before aborting the job | +| `QUIZ_ARCHIVER_PREVENT_REDIRECT_TO_LOGIN` | `True` | Whether to supress all redirects to Moodle login pages (`/login/*.php`) after page load | +| `QUIZ_ARCHIVER_DEMO_MODE` | `False` | Whether the app is running in demo mode. In demo mode, a watermark will be added to all generated PDFs, only a limited number of attempts will be exported per archive job, and only placeholder Moodle backups are included | + + + +## Next Steps + +After installing both the Moodle plugin and the archive worker service, you +need to perform the initial [configuration](/configuration) once, to make the +plugin work. + +[:material-cog: Configuration](/configuration){ .md-button } diff --git a/docs/installation/index.md b/docs/installation/index.md new file mode 100644 index 0000000..6c3d104 --- /dev/null +++ b/docs/installation/index.md @@ -0,0 +1,92 @@ +# Installation + +This section provides instructions on how to install the quiz archiver Moodle +plugin. + +If you encounter any issues during the installation process, please open a bug +report or ask a question in the issue tracker over on GitHub. + +[:simple-github: Issue Tracker](https://github.com/ngandrass/moodle-quiz_archiver/issues){ .md-button } + + +## Overview + +Archive jobs are execute via an external quiz archive worker service. It uses the +Moodle webservice API to query the required data and to upload the created archive. + +This plugin prepares the archive job within Moodle, provides quiz data to the +archive worker, handles data validation, and stores the created quiz archives +inside the Moodle filestore. Created archives can be managed and downloaded via +the Moodle web interface. A unique webservice access token is generated for every +archive job. Each token has a limited validity and is invalidated either after +job completion or after a specified timeout. This process requires a dedicated +webservice user to be created (see [Configuration](/configuration)). A single job +webservice token can only be used for the specific quiz that is associated with +the job to restrict queryable data to the required minimum. + + +## Requirements + +In order to use the quiz archiver plugin, you need to have the following +prerequisites met: + +- Moodle 4.1 (LTS) or newer +- PHP 7.4 or newer +- PostgreSQL or MariaDB / MySQL +- Admin access to the Moodle instance and shell access to the server (e.g., SSH) + +!!! danger "A note on PHP versions" + Please always use the **most recent version of PHP** supported by your + Moodle version. Older PHP versions contain security vulnerabilities and bugs. + + You can check the specific requirements and supported software versions for + your specific Moodle version over at [the Moodle Docs](https://moodledev.io/general/releases). + + + +## Versioning and Compatibility + +The [quiz_archiver Moodle Plugin](https://github.com/ngandrass/moodle-quiz_archiver) +and its corresponding [Quiz Archive Worker](https://github.com/ngandrass/moodle-quiz-archive-worker) +both use [Semantic Versioning 2.0.0](https://semver.org/). + +This means that their version numbers are structured as `MAJOR.MINOR.PATCH`. The +Moodle plugin and the archive worker service are compatible as long as they use +the same `MAJOR` version number. Minor and patch versions can differ between the +two components without breaking compatibility. + +However, it is **recommended to always use the latest version** of both the +Moodle plugin and the archive worker service to ensure you get all the latest +bug fixes, features, and optimizations. + + +### Compatibility Examples + +| Moodle Plugin | Archive Worker | Compatible | +|---------------|----------------|------------| +| 1.0.0 | 1.0.0 | Yes | +| 1.2.3 | 1.0.0 | Yes | +| 1.0.0 | 1.1.2 | Yes | +| 2.1.4 | 2.0.1 | Yes | +| | | | +| 2.0.0 | 1.0.0 | No | +| 1.0.0 | 2.0.0 | No | +| 2.4.2 | 1.4.2 | No | + + +### Development / Testing Versions + +Special development versions, used for testing, can be created but will never be +published to the Moodle plugin directory. Such development versions are marked +by a `+dev-[TIMESTAMP]` suffix, e.g., `2.4.2+dev-2022010100`. + + +## Next Steps + +This plugin requires the installation of the +[quiz_archiver Moodle plugin](/installation/moodleplugin) Moodle plugin and an +additional [quiz archive worker service](/installation/archiveworker) to work. + +[:simple-moodle: Installation: Moodle Plugin](/installation/moodleplugin){ .md-button } +    +[:simple-docker: Installation: Archive Worker Service](/installation/archiveworker){ .md-button } diff --git a/docs/installation/moodleplugin.md b/docs/installation/moodleplugin.md new file mode 100644 index 0000000..c0e3163 --- /dev/null +++ b/docs/installation/moodleplugin.md @@ -0,0 +1,42 @@ +# Moodle Plugin + +You can install this plugin like any other Moodle plugin, as described below. +However, keep in mind that you additionally need to deploy the external [quiz +archive worker service](/installation/archiveworker) for this plugin to work. + + +### Installing via uploaded ZIP file + +1. Log in to your Moodle site as an admin and go to _Site administration > + Plugins > Install plugins_. +2. Upload the ZIP file with the plugin code. You should only be prompted to add + extra details if your plugin type is not automatically detected. +3. Check the plugin validation report and finish the installation. + + +### Installing manually + +The plugin can be also installed by putting the contents of this directory to + +```text +{your/moodle/dirroot}/mod/quiz/report/archiver +``` + +Afterward, log in to your Moodle site as an admin and go to _Site administration > +Notifications_ to complete the installation. + +Alternatively, you can run + +```text +php admin/cli/upgrade.php +``` + +to complete the installation from the command line. + + +## Next Steps + +After installing the Moodle plugin, you need to install the additional [quiz +archive worker service](/installation/archiveworker) to make the plugin work. + +[:simple-docker: Installation: Archive Worker Service](/installation/archiveworker){ .md-button } diff --git a/docs/screenshots.md b/docs/screenshots.md new file mode 100644 index 0000000..57fd172 --- /dev/null +++ b/docs/screenshots.md @@ -0,0 +1,16 @@ +# Screenshots + +## Quiz Archiver overview page +![Image of quiz archiver overview page](assets/screenshots/quiz_archiver_overview_page.png) + +## New job queued while another job is running +![Image of new job queued while another job is running](assets/screenshots/quiz_archiver_new_job_queued.png) + +## Quiz archive job details +![Image of quiz archive job details](assets/screenshots/quiz_archiver_job_details_modal.png) + +## Example of PDF report (excerpts) +![Image of example of PDF report (extract): Header](assets/screenshots/quiz_archiver_report_example_pdf_header.png) +![Image of example of PDF report (extract): Question 1](assets/screenshots/quiz_archiver_report_example_pdf_question_1.png) +![Image of example of PDF report (extract): Question 2](assets/screenshots/quiz_archiver_report_example_pdf_question_2.png) +![Image of example of PDF report (extract): Question 3](assets/screenshots/quiz_archiver_report_example_pdf_question_3.png) diff --git a/docs/usage/archivingbasic.md b/docs/usage/archivingbasic.md new file mode 100644 index 0000000..ae35c6e --- /dev/null +++ b/docs/usage/archivingbasic.md @@ -0,0 +1,49 @@ +# Creating Quiz Archives + +Once the Moodle plugin and the archive worker service are [installed](/installation) +and [set up](/configuration), quizzes can be archived by performing the following steps: + +1. Navigate to a Moodle quiz +2. Select the _Results_ tab (1), open the dropdown menu (2), and select _Quiz Archiver_ +3. Set your desired archiving options (3) and initiate a new archive job by + clicking the _Archive quiz_ button (4) +4. Confirm that your archive job was created (5) and wait for it to finish. You + can check its current status using the refresh button (6) +5. Once the job is completed, you can download the archive by clicking the + _Download archive_ button (7) + +![Screenshot: Creating a new quiz archive 1](/assets/configuration/configuration_quiz_archive_creation_1.png){ .img-thumbnail } +![Screenshot: Creating a new quiz archive 2](/assets/configuration/configuration_quiz_archive_creation_2.png){ .img-thumbnail } +![Screenshot: Creating a new quiz archive 3](/assets/configuration/configuration_quiz_archive_creation_3.png){ .img-thumbnail } +![Screenshot: Creating a new quiz archive 4](/assets/configuration/configuration_quiz_archive_creation_4.png){ .img-thumbnail } + + +## Inspecting Quiz Archive Details + +To inspect the details of a quiz archive job, click the _Show details_ button (1). +This will open a modal dialog showing all relevant information. + +![Screenshot: Inspecting a quiz archive 1](/assets/configuration/configuration_quiz_archive_inspection_1.png){ .img-thumbnail }
    +![Screenshot: Inspecting a quiz archive 2](/assets/configuration/configuration_quiz_archive_inspection_2.png){ .img-thumbnail } + + +## Downloading Quiz Archives + +Once a quiz archive job is finished, the archive can be downloaded by clicking +the _Download archive_ button (1) located inside the job overview table or within +each archive jobs details dialog. + +![Screenshot: Downloading a quiz archive](/assets/configuration/configuration_quiz_archive_download_1.png){ .img-thumbnail } + + +## Deleting Quiz Archives + +!!! danger + This action is irreversible and will permanently delete the archive and all + associated data. Make sure to download the archive before deleting it. + +Created archives can be deleted by clicking the _Delete archive_ (1) button +within the job overview table. + +![Screenshot: Deleting a quiz archive](/assets/configuration/configuration_quiz_archive_delete_1.png){ .img-thumbnail } + diff --git a/docs/usage/automaticdeletion.md b/docs/usage/automaticdeletion.md new file mode 100644 index 0000000..2f7f2d4 --- /dev/null +++ b/docs/usage/automaticdeletion.md @@ -0,0 +1,43 @@ +# Automatic Deletion (GDPR Compliance) + +Quiz archives can be automatically deleted after a specified retention period. +Automatic deletion can either be controlled on a per-archive basis or globally +via the [archive job presets](/configuration/policies). + +Archives with expired lifetimes are deleted by an asynchronous task that is, by +default, scheduled to run every hour. Only the archived user data (attempt PDFs, +attachments, ...) is deleted, while the job metadata is kept until manually +deleted. This procedure allows to document the deletion of archive data in a +traceable manner, while the privacy relevant user data is deleted. + +![Screenshot: Job details modal - Automatic deletion](/assets/screenshots/quiz_archiver_job_details_modal_autodelete.png){ .img-thumbnail } + +If an archive is scheduled for automatic deletion, its remaining lifetime is +shown in the job details modal, as depict above. You can access it via the +_Show details_ button on the quiz archiver overview page. Once deleted, archives +change their status from `Finished` to `Deleted`. + +!!! info + If you try to delete an archive that is scheduled for automatic deletion + before its retention period expired, an extra warning message will be shown + to prevent accidental deletions. + + +## Enabling Automatic Deletion for a Single Quiz Archive + +To enable the scheduled deletion for a single quiz archive: + +1. Navigate to the quiz archiver overview page +2. Expand the _Advanced settings_ section of the _Create new quiz archive_ form +3. Check the _Automatic deletion_ checkbox (1) +4. Set the desired retention period (2) +5. Create the archive job (3) + +![Screenshot: Configuration - Automatic archive deletion](/assets/configuration/configuration_job_autodelete.png){ .img-thumbnail } + + +## Enabling Automatic Deletion Globally + +Like any other archive settings, automatic deletion can be configured globally +using the [archive job presets](/configuration/policies) within the plugin +configuration. diff --git a/docs/usage/imageoptimization.md b/docs/usage/imageoptimization.md new file mode 100644 index 0000000..1c454cb --- /dev/null +++ b/docs/usage/imageoptimization.md @@ -0,0 +1,27 @@ +# Image optimization + +If quizzes contain a large number of images or images with an excessively high +resolutions (e.g., 4000x3000 px), the quiz archiver can optionally compress such +images during archiving. This can significantly reduce the size of the generated +PDF files. HTML source files, if generated, are never modified and remain +untouched. + +To enable image optimization for a quiz archive job: + +1. Navigate to the quiz archiver overview page +2. Expand the _Advanced settings_ section of the _Create new quiz archive_ form +3. Check the _Optimize images_ checkbox (1) +4. Set the desired maximum dimensions and quality (2) + - If an image exceeds any of the specified dimensions, it will be resized + proportionally to fit within the specified bounds. + - The quality setting controls the compression level of the images. A value + of 100% will result in no compression, while a value of 0% will result in + the lowest quality and smallest file size. A value of 85% is a good + compromise between quality and file size. +5. Continue with the archive creation as usual + +![Screenshot: Configuration - Image optimization](/assets/configuration/configuration_job_image_optimization.png){ .img-thumbnail } + +!!! info "Suggestion" + It is strongly advised to lock image quality settings to global defaults + using the [archive job presets](/configuration/presets). diff --git a/docs/usage/index.md b/docs/usage/index.md new file mode 100644 index 0000000..fd79b23 --- /dev/null +++ b/docs/usage/index.md @@ -0,0 +1,18 @@ +# Usage + +This section discusses the usage of the quiz archiver plugin. It covers the +[basic creation of quiz archives](/usage/archivingbasic) as well as advanced +topics, such as [image optimization](/usage/imageoptimization) or +[quiz archive signing](/usage/tsp). + + +## Getting Started + +To get started with the quiz archiver plugin, check out the +[quiz archive creation guide](/usage/archivingbasic). + +[:material-archive: Creating Quiz Archives](/usage/archivingbasic){ .md-button } + +## Other Topics + +To get information on other topics, exlore the navigation on the right. diff --git a/docs/usage/tsp.md b/docs/usage/tsp.md new file mode 100644 index 0000000..5207c0f --- /dev/null +++ b/docs/usage/tsp.md @@ -0,0 +1,79 @@ +# Quiz Archive Signing (TSP) + +Quiz archives and their creation date can be digitally signed by a trusted +authority using the [Time-Stamp Protocol (TSP)](https://en.wikipedia.org/wiki/Time_stamp_protocol) +according to [RFC 3161](https://www.ietf.org/rfc/rfc3161.txt). This can be used +to cryptographically prove the integrity and creation date of the archive at a +later point in time. Quiz archives can be signed automatically at creation or +manually later on. + + +## Enabling Archive Signing + +Prior to the first archive signing, the TSP service must be set up once within +the plugin settings. To do so, follow these steps: + +1. Navigate to _Site Administration_ > _Plugins_ (1) > _Activity modules_ > + _Quiz_ > _Quiz Archiver_ (2) +2. Set `tsp_server_url` (3) to the URL of your desired TSP service +3. Globally enable archive signing by checking `tsp_enable` (4) +4. (Optional) Enable automatic archive signing by checking `tsp_automatic_signing` (5) +5. Save all settings (6) + +![Screenshot: Configuration - TSP Settings 1](/assets/configuration/configuration_plugin_settings_1.png){ .img-thumbnail } +![Screenshot: Configuration - TSP Settings 2](/assets/configuration/configuration_tsp_settings_2.png){ .img-thumbnail } + + +## Signing Quiz Archives + +Quiz archives can be signed either automatically during their creation or +manually at a later point in time. + +### Automatic Archive Signing + +If enabled, new archives will be automatically signed during creation. TSP data +can be accessed via the _Show details_ button of an archive job on the quiz +archiver overview page. Existing archives will not be signed automatically (see +[Manual archive signing](#manual-archive-signing)). + +### Manual Archive Signing + +To manually sign a quiz archive, navigate to the quiz archiver overview page, +click the _Show details_ button for the desired archive job, and click the +_Sign archive now_ button. + + +## Accessing TSP Data + +Both the TSP query and the TSP response can be accessed via the job details +dialog. To do so, navigate to the quiz archiver overview page and click the +_Show details_ button for the desired archive job. + +![Image of archive job details: TSP data](/assets/screenshots/quiz_archiver_job_details_modal_tsp_data.png){ .img-thumbnail } + + +## Validating an Archive and its Signature + +To validate an archive and its signature, install `openssl` and conduct the +following steps: + +1. Obtain the certificate files from your TSP authority (`.crt` and `.pem`)[^1] +2. Navigate to the quiz archiver overview page and click the _Show details_ + button for the desired archive job to check +3. Download the archive and both TSP files (`.tsq` and `.tsr`) +4. Inspect the TSP response to see the timestamp and signed hash value + 1. Execute: `openssl ts -reply -in .tsr -text` +5. Verify the quiz archive (`.tar.gz`) against the TSP response + (`archive.tsr`). This process confirms that the archive was signed by the TSP + authority and that the archive was not modified after signing, i.e., the hash + values of the file matches the TSP response. + 1. Execute: `openssl ts -verify -in .tsr -data .tar.gz -CAfile .pem -untrusted .crt` + 2. Verify that the output is `Verification: OK`
    + Errors are indicated by `Verification: FAILED` +6. (Optional) Verify that TSP request and TSP response match + 1. Execute: `openssl ts -verify -in .tsr -queryfile .tsq -CAfile .pem -untrusted .crt` + 2. Verify that the output is `Verification: OK`
    + Errors are indicated by `Verification: FAILED` + +[^1]: The certificate must be given by your TSP authority. You can usually find +it on the website of the service. \ No newline at end of file diff --git a/lang/de/quiz_archiver.php b/lang/de/quiz_archiver.php index bf9d518..d98bb8b 100644 --- a/lang/de/quiz_archiver.php +++ b/lang/de/quiz_archiver.php @@ -22,71 +22,93 @@ * @copyright 2024 Niels Gandraß * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +// @codingStandardsIgnoreFile $string['pluginname'] = 'Quiz Archiver'; $string['archiver'] = 'Quiz Archiver'; $string['archiverreport'] = 'Quiz Archiver'; $string['checksum'] = 'Prüfsumme'; $string['beta_version_warning'] = 'Dieses Plugin befindet sich derzeit in der Beta-Phase. Bitte melden Sie alle Probleme und Fehler dem Website-Administrator.'; +$string['thanks_for_installing'] = 'Vielen Dank für die Installation des Quiz Archiver Plugins!'; +$string['go_to_plugin_settings'] = 'Plugin-Einstellungen öffnen'; +$string['manual_configuration_continue'] = 'Um alle Plugin-Einstellungen manuell zu setzen, verwenden Sie die Schaltfläche "Weiter" am Ende dieser Seite.'; -// Capabilities -$string['quiz_archiver:view'] = 'Quiz Archiver Berichtsseite anzeigen'; +// Capabilities. +$string['quiz_archiver:view'] = 'Quiz Archiver Seite anzeigen'; $string['quiz_archiver:archive'] = 'Erstellen und Löschen von Testarchiven'; $string['quiz_archiver:use_webservice'] = 'Webservice des Quiz Archivers nutzen (lesend und schreibend)'; -// General +// General. +$string['a'] = '{$a}'; +$string['progress'] = 'Fortschritt'; $string['quiz_archive'] = 'Testarchiv'; $string['quiz_archive_details'] = 'Details des Testarchivs'; $string['quiz_archive_not_found'] = 'Testarchiv nicht gefunden'; $string['quiz_archive_not_ready'] = 'Testarchiv noch nicht bereit'; -// Template: Overview +// Template: Overview. $string['archived'] = 'Archiviert'; $string['users_with_attempts'] = 'Nutzende mit Versuchen'; $string['archive_autodelete'] = 'Automatische Löschung'; $string['archive_autodelete_short'] = 'Löschung'; -$string['archive_autodelete_help'] = 'Automatisches Löschen dieses Testarchivs nach einer bestimmten Zeit. Die Aufbewahrungszeit kann konfiguriert werden, sobald die automatische Löschung aktiviert ist.'; +$string['archive_autodelete_help'] = 'Automatisches Löschen dieses Testarchivs nach einer bestimmten Zeit. Die Speicherdauer kann konfiguriert werden, sobald die automatische Löschung aktiviert ist.'; $string['archive_quiz'] = 'Test archivieren'; -$string['archive_retention_time'] = 'Aufbewahrungszeit'; -$string['archive_retention_time_help'] = 'Die Aufbewahrungszeit dieses Testarchivs, bevor es automatisch gelöscht wird. Diese Einstellung hat nur Auswirkungen, wenn die automatische Löschung aktiviert ist.'; -$string['create_quiz_archive'] = 'Neues Archiv erstellen'; -$string['archive_quiz_form_desc'] = 'Füllen Sie dieses Formular aus, um den Test zu archivieren. Die Archivierung findet asynchron statt und kann einige Zeit in Anspruch nehmen. Sie können den aktuellen Status jederzeit auf dieser Seite überprüfen und fertige Archive herunterladen.'; +$string['archive_retention_time'] = 'Speicherdauer'; +$string['archive_retention_time_help'] = 'Die Speicherdauer dieses Testarchivs, bevor es automatisch gelöscht wird. Diese Einstellung hat nur Auswirkungen, wenn die automatische Löschung aktiviert ist.'; +$string['create_quiz_archive'] = 'Neues Testarchiv erstellen'; +$string['archive_quiz_form_desc'] = 'Verwenden Sie dieses Formular um den ausgewählten Test zu archivieren. Die Archivierung findet asynchron statt und kann einige Zeit in Anspruch nehmen. Sie können den aktuellen Status jederzeit auf dieser Seite überprüfen sowie fertige Archive herunterladen.'; $string['error_archive_quiz_form_validation_failed'] = 'Validierung der gesendeten Formulardaten fehlgeschlagen. Bitte überprüfen Sie Ihre Eingaben.'; +$string['error_plugin_is_not_configured'] = 'Fehler: Das Quiz Archiver Plugin ist noch nicht konfiguriert. Bitte kontaktieren Sie Ihren Website-Administrator.'; +$string['error_quiz_cannot_be_archived_unknown'] = 'Dieser Test kann aufgrund eines unbekannten Fehlers nicht archiviert werden. Bitte melden Sie dieses Problem an die Plugin-Entwickler.'; $string['export_attempts'] = 'Testversuche exportieren'; $string['export_attempts_help'] = 'Es werden stets alle Testversuche exportiert'; $string['export_attempts_num'] = 'Testversuche ({$a}) exportieren'; $string['export_attempts_num_help'] = 'Es werden stets alle Testversuche exportiert'; +$string['export_attempts_image_optimize'] = 'Bilder optimieren'; +$string['export_attempts_image_optimize_help'] = 'Wenn aktiviert, werden Bilder innerhalb der Versuchsberichte komprimiert und große Bilder unter Berücksichtigung der unten angegebenen Dimensionen verkleinert. Bilder werden ausschließlich verkleinert. Dies betrifft nur PDF-Exporte. HTML-Quelldateien behalten stets die Originalbildgröße bei.'; +$string['export_attempts_image_optimize_group'] = 'Maximale Bildauflösung'; +$string['export_attempts_image_optimize_group_help'] = 'Maximale Auflösung für Bilder innerhalb der Versuchsberichte in Pixeln (Breite x Höhe). Wenn ein Bild breiter oder höher als die angegebenen Dimensionen ist, wird es so verkleinert, dass es vollständig in die angegebenen Dimensionen passt. Das Seitenverhältnis wird dabei beibehalten. Dies kann nützlich sein, um die Gesamtgröße des Archivs zu reduzieren, wenn große Bilder im Test verwendet werden.'; +$string['export_attempts_image_optimize_height'] = 'Maximale Bildhöhe'; +$string['export_attempts_image_optimize_height_help'] = 'Maximale Höhe für Bilder innerhalb der Versuchsberichte in Pixeln. Wenn ein Bild höher als die angegebene Höhe ist, wird es auf die angegebene Höhe verkleinert, wobei das Seitenverhältnis beibehalten wird.'; +$string['export_attempts_image_optimize_quality'] = 'Bildkompression'; +$string['export_attempts_image_optimize_quality_help'] = 'Qualität der komprimierten Bilder (0 - 100 %). Je höher die Qualität, desto größer die Versuchsberichte. Diese Einstellung verhält sich wie die JPEG-Kompressionsintensität. Ein guter Richtwert sind 85 %.'; +$string['export_attempts_image_optimize_width'] = 'Maximale Bildbreite'; +$string['export_attempts_image_optimize_width_help'] = 'Maximale Breite für Bilder innerhalb der Versuchsberichte in Pixeln. Wenn ein Bild breiter als die angegebene Breite ist, wird es auf die angegebene Breite verkleinert, wobei das Seitenverhältnis beibehalten wird.'; $string['export_attempts_keep_html_files'] = 'HTML-Dateien'; $string['export_attempts_keep_html_files_desc'] = 'HTML-Quelldateien behalten'; $string['export_attempts_keep_html_files_help'] = 'Speichert die HTML-Quelldateien zusätzlich zu den erzeugten PDFs während des Exportvorgangs. Dies kann nützlich sein, wenn Sie auf den HTML DOM zugreifen möchten, aus dem die PDFs erzeugt wurden. Deaktivieren dieser Option kann die Archivgröße deutlich reduzieren!'; $string['export_attempts_paper_format'] = 'Papierformat'; $string['export_attempts_paper_format_help'] = 'Das Papierformat für den PDF-Export. Dies hat keinen Einfluss auf HTML-Exporte.'; $string['export_course_backup'] = 'Vollständiges Moodle Kursbackup (.mbz) erzeugen'; -$string['export_course_backup_help'] = 'Erzeugt ein vollständiges Moodle Kursbackup (.mbz) mit allen Kursinhalten und -einstellungen. Dies kann genutzt werden, um den gesamten Kurs in ein anderes Moodle-System zu importieren.'; +$string['export_course_backup_help'] = 'Erzeugt ein vollständiges Moodle Kursbackup (.mbz) mit allen Kursinhalten und -einstellungen. Dies kann genutzt werden, um den gesamten Kurs in einem anderen Moodle-System zu importieren.'; $string['export_quiz_backup'] = 'Moodle Testbackup (.mbz) erzeugen'; -$string['export_quiz_backup_help'] = 'Erzeugt ein Moodle Testbackup (.mbz) mit allen Testinhalten und Fragen. Dies kann genutzt werden, um den Test unabhängig von diesem Kurs in ein anderes Moodle-System zu importieren.'; +$string['export_quiz_backup_help'] = 'Erzeugt ein Moodle Testbackup (.mbz) mit allen Testinhalten und Fragen. Dies kann genutzt werden, um den Test unabhängig von diesem Kurs in einem anderen Moodle-System zu importieren.'; $string['export_report_section_header'] = 'Test-Metadaten einschließen'; $string['export_report_section_header_help'] = 'Metadaten des Versuchs (z.B. Teilnehmender, Startzeitpunkt, Endzeitpunkt, Bewertung, ...) im Bericht einschließen'; $string['export_report_section_question'] = 'Fragen einschließen'; -$string['export_report_section_question_help'] = 'Alle Fragen des Tests im Bericht einschließen'; +$string['export_report_section_question_help'] = 'Alle Fragen des Versuchs im Bericht einschließen'; $string['export_report_section_rightanswer'] = 'Richtige Antworten einschließen'; -$string['export_report_section_rightanswer_help'] = 'Richtige Antworten der Fragen im Bericht einschließen'; +$string['export_report_section_rightanswer_help'] = 'Richtige Antworten für alle Fragen im Bericht einschließen'; $string['export_report_section_quiz_feedback'] = 'Testfeedback einschließen'; $string['export_report_section_quiz_feedback_help'] = 'Generelles Test-Feedback im Bericht einschließen'; $string['export_report_section_question_feedback'] = 'Individuelles Fragenfeedback einschließen'; $string['export_report_section_question_feedback_help'] = 'Individuelles Fragenfeedback im Bericht einschließen'; $string['export_report_section_general_feedback'] = 'Allgemeines Fragenfeedback einschließen'; $string['export_report_section_general_feedback_help'] = 'Allgemeines Fragenfeedback im Bericht einschließen'; -$string['export_report_section_history'] = 'Bearbeitungsverlauf einschließen'; -$string['export_report_section_history_help'] = 'Bearbeitungsverlauf der Testfragen im Bericht einschließen'; +$string['export_report_section_history'] = 'Antworthistorie einschließen'; +$string['export_report_section_history_help'] = 'Antworthistorie für alle Testfragen im Bericht einschließen'; $string['export_report_section_attachments'] = 'Dateiabgaben einschließen'; -$string['export_report_section_attachments_help'] = 'Alle Dateiabgaben (z.B. von Aufsätzen/Essay Aufgaben) im Archiv einschließen. Warnung: Dies kann die Archivgröße erheblich erhöhen.'; +$string['export_report_section_attachments_help'] = 'Alle Dateiabgaben (z.B. von Freitextaufgaben) im Archiv einschließen. Warnung: Dies kann die Archivgröße erheblich erhöhen.'; $string['job_overview'] = 'Testarchive'; -$string['num_attempts'] = 'Anzahl Testversuche'; +$string['last_updated'] = 'Zuletzt aktualisiert'; +$string['num_attempts'] = 'Anzahl der Testversuche'; -// Job creation form: Filename pattern +// Job creation form: Filename pattern. $string['archive_filename_pattern'] = 'Archivname'; $string['archive_filename_pattern_help'] = 'Name des erzeugten Archivs. Variablen müssen dem ${variablename} Muster folgen. Die Dateiendung wird automatisch hinzugefügt.

    Verfügbare Variablen:
      {$a->variables}
    Verbotene Zeichen: {$a->forbiddenchars}'; +// TODO (MDL-0): Remove the following 2 lines after deprecation of Moodle 4.1 (LTS) on 08-12-2025. +$string['archive_filename_pattern_moodle42'] = 'Archivname'; +$string['archive_filename_pattern_moodle42_help'] = 'Name des erzeugten Archivs. Variablen müssen dem ${variablename} Muster folgen. Die Dateiendung wird automatisch hinzugefügt.

    Verfügbare Variablen:
    • ${courseid}: Kurs-ID
    • ${coursename}: Kursname
    • ${courseshortname}: Kurzer Kursname
    • ${cmid}: Kursmodul-ID
    • ${quizid}: Test-ID
    • ${quizname}: Testname
    • ${date}: Aktuelles Datum (YYYY-MM-DD)
    • ${time}: Aktuelle Uhrzeit (HH-MM-SS)
    • ${timestamp}: Aktueller Unix-Zeitstempel
    Verbotene Zeichen: \/.:;*?!"<>|'; $string['archive_filename_pattern_variable_courseid'] = 'Kurs-ID'; $string['archive_filename_pattern_variable_coursename'] = 'Kursname'; $string['archive_filename_pattern_variable_courseshortname'] = 'Kurzer Kursname'; @@ -95,11 +117,13 @@ $string['archive_filename_pattern_variable_quizname'] = 'Testname'; $string['archive_filename_pattern_variable_date'] = 'Aktuelles Datum (YYYY-MM-DD)'; $string['archive_filename_pattern_variable_time'] = 'Aktuelle Uhrzeit (HH-MM-SS)'; -$string['archive_filename_pattern_variable_timestamp'] = 'Aktueller Unix Timestamp'; +$string['archive_filename_pattern_variable_timestamp'] = 'Aktueller Unix-Zeitstempel'; $string['error_invalid_archive_filename_pattern'] = 'Ungültiger Archivname. Bitte korrigieren Sie Ihre Eingabe und versuchen Sie es erneut.'; - $string['export_attempts_filename_pattern'] = 'Versuchsname'; $string['export_attempts_filename_pattern_help'] = 'Name eines archivierten Versuchs. Variablen müssen dem ${variablename} Muster folgen. Die Dateiendung wird automatisch hinzugefügt.

    Verfügbare Variablen:
      {$a->variables}
    Verbotene Zeichen: {$a->forbiddenchars}'; +// TODO (MDL-0): Remove the following 2 lines after deprecation of Moodle 4.1 (LTS) on 08-12-2025. +$string['export_attempts_filename_pattern_moodle42'] = 'Versuchsname'; +$string['export_attempts_filename_pattern_moodle42_help'] = 'Name eines archivierten Versuchs. Variablen müssen dem ${variablename} Muster folgen. Die Dateiendung wird automatisch hinzugefügt.

    Verfügbare Variablen:
    • ${courseid}: Kurs-ID
    • ${coursename}: Kursname
    • ${courseshortname}: Kurzer Kursname
    • ${cmid}: Kursmodul-ID
    • ${quizid}: Test-ID
    • ${quizname}: Testname
    • ${attemptid}: Versuchs-ID
    • ${username}: Nutzer Anmeldename
    • ${firstname}: Nutzer Vorname
    • ${lastname}: Nutzer Nachname
    • ${timestart}: Versuchsstart (Unix-Zeitstempel)
    • ${timefinish}: Versuchsende (Unix-Zeitstempel)
    • ${date}: Aktuelles Datum (YYYY-MM-DD)
    • ${time}: Aktuelle Uhrzeit (HH-MM-SS)
    • ${timestamp}: Aktueller Unix-Zeitstempel
    Verbotene Zeichen: \/.:;*?!"<>|'; $string['export_attempts_filename_pattern_variable_courseid'] = 'Kurs-ID'; $string['export_attempts_filename_pattern_variable_coursename'] = 'Kursname'; $string['export_attempts_filename_pattern_variable_courseshortname'] = 'Kurzer Kursname'; @@ -110,32 +134,48 @@ $string['export_attempts_filename_pattern_variable_username'] = 'Nutzer Anmeldename'; $string['export_attempts_filename_pattern_variable_firstname'] = 'Nutzer Vorname'; $string['export_attempts_filename_pattern_variable_lastname'] = 'Nutzer Nachname'; -$string['export_attempts_filename_pattern_variable_timestart'] = 'Versuchsstart (Unix Timestamp)'; -$string['export_attempts_filename_pattern_variable_timefinish'] = 'Versuchsende (Unix Timestamp)'; +$string['export_attempts_filename_pattern_variable_idnumber'] = 'Nutzer ID-Nummer'; +$string['export_attempts_filename_pattern_variable_timestart'] = 'Versuchsstart (Unix-Zeitstempel)'; +$string['export_attempts_filename_pattern_variable_timefinish'] = 'Versuchsende (Unix-Zeitstempel)'; $string['export_attempts_filename_pattern_variable_date'] = 'Aktuelles Datum (YYYY-MM-DD)'; $string['export_attempts_filename_pattern_variable_time'] = 'Aktuelle Uhrzeit (HH-MM-SS)'; -$string['export_attempts_filename_pattern_variable_timestamp'] = 'Aktueller Unix Timestamp'; +$string['export_attempts_filename_pattern_variable_timestamp'] = 'Aktueller Unix-Zeitstempel'; $string['error_invalid_attempt_filename_pattern'] = 'Ungültiger Versuchsname. Bitte korrigieren Sie Ihre Eingabe und versuchen Sie es erneut.'; -// Job +// Job. $string['delete_artifact'] = 'Testarchiv löschen'; +$string['delete_artifact_success'] = 'Testarchiv des Archivierungsauftrags mit der ID {$a} wurde erfolgreich gelöscht. Die Auftragsmetadaten existieren weiterhin und können mit der Schaltfläche "Archivierungsauftrag löschen" endgültig gelöscht werden.'; $string['delete_artifact_warning'] = 'Sind Sie sicher, dass Sie dieses Testarchiv inklusive aller archivierten Daten löschen möchten?. Die Metadaten des Archivierungsauftrags werden hierbei nicht gelöscht.'; $string['delete_job'] = 'Archivierungsauftrag löschen'; +$string['delete_job_success'] = 'Archivierungsauftrag mit der ID {$a} wurde erfolgreich gelöscht.'; $string['delete_job_warning'] = 'Sind Sie sicher, dass Sie diesen Archivierungsauftrag inklusive aller archivierten Daten löschen möchten?'; $string['delete_job_warning_retention'] = 'Achtung: Dieser Archivierungsauftrag ist für die automatische Löschung am {$a} vorgesehen. Sind Sie absolut sicher, dass Sie ihn vor Ablauf seiner geplanten Lebensdauer löschen möchten?'; $string['jobid'] = 'Auftrags-ID'; -$string['job_created_successfully'] = 'Neuer Archivierungsauftrag erfolgreich erstellt: {$a}'; +$string['job_created_successfully'] = 'Neuer Archivierungsauftrag erfolgreich erstellt. Auftrags-ID: {$a}'; $string['job_status_UNKNOWN'] = 'Unbekannt'; -$string['job_status_UNINITIALIZED'] = 'Nicht initialisiert'; +$string['job_status_UNKNOWN_help'] = 'Der Status dieses Auftrags ist unbekannt. Bitte melden Sie dieses Problem, wenn es weiterhin besteht.'; +$string['job_status_UNINITIALIZED'] = 'Neu'; +$string['job_status_UNINITIALIZED_help'] = 'Der Auftrag wurde noch nicht initialisiert.'; $string['job_status_AWAITING_PROCESSING'] = 'Wartend'; +$string['job_status_AWAITING_PROCESSING_help'] = 'Der Auftrag wurde erfasst und wartet auf die Verarbeitung durch den Archive Worker Service.'; $string['job_status_RUNNING'] = 'Läuft'; +$string['job_status_RUNNING_help'] = 'Der Auftrag wird derzeit vom Archive Worker Service verarbeitet. Der Fortschritt des Auftrags wird periodisch aktualisiert (Standard: alle 15 Sekunden).'; +$string['job_status_WAITING_FOR_BACKUP'] = 'Backup ausstehend'; +$string['job_status_WAITING_FOR_BACKUP_help'] = 'Der Auftrag wartet auf die Erstellung eines Moodle-Backups. Dies kann je nach Kursgröße einige Zeit in Anspruch nehmen.'; +$string['job_status_FINALIZING'] = 'Finalisieren'; +$string['job_status_FINALIZING_help'] = 'Der Archive Worker Service finalisiert das Archiv und überträgt es an Moodle. Dies kann je nach Größe des Archivs einige Zeit in Anspruch nehmen.'; $string['job_status_FINISHED'] = 'Fertig'; +$string['job_status_FINISHED_help'] = 'Der Auftrag wurde erfolgreich abgeschlossen. Das Archiv ist bereit zum Download.'; $string['job_status_FAILED'] = 'Fehler'; +$string['job_status_FAILED_help'] = 'Der Auftrag ist fehlgeschlagen. Bitte versuchen Sie es erneut und kontaktieren Sie Ihren Systemadministrator, wenn das Problem weiterhin besteht.'; $string['job_status_TIMEOUT'] = 'Zeitüberschreitung'; +$string['job_status_TIMEOUT_help'] = 'Der Auftrag wurde aufgrund einer Zeitüberschreitung abgebrochen. Dies kann bei sehr großen Tests passieren. Bitte kontaktieren Sie Ihren Systemadministrator, wenn das Problem weiterhin besteht.'; $string['job_status_DELETED'] = 'Gelöscht'; +$string['job_status_DELETED_help'] = 'Das Testarchiv und alle zugehörigen Daten wurden entfernt. Die Auftragsmetadaten existieren weiterhin und können bei Bedarf endgültig gelöscht werden.'; -// Job details +// Job details. $string['archive_already_signed'] = 'Testarchiv ist bereits signiert'; +$string['archive_already_signed_with_jobid'] = 'Testarchiv des Archivierungsauftrag mit der ID {$a} ist bereits signiert.'; $string['archive_autodelete_deleted'] = 'Testarchive wurde automatisch gelöscht'; $string['archive_autodelete_in'] = 'Testarchiv wird gelöscht in {$a}'; $string['archive_autodelete_disabled'] = 'Deaktiviert'; @@ -144,9 +184,12 @@ $string['archive_not_signed'] = 'Testarchiv ist nicht signiert'; $string['archive_signature'] = 'Signatur'; $string['archive_signed_successfully'] = 'Testarchiv erfolgreich signiert'; +$string['archive_signed_successfully_with_jobid'] = 'Testarchiv des Archivierungsauftrag mit der ID {$a} wurde erfolgreich signiert.'; $string['archive_signing_failed'] = 'Signierung des Testarchivs fehlgeschlagen'; +$string['archive_signing_failed_with_jobid'] = 'Signierung des Testarchivs des Archivierungsauftrags mit der ID {$a} ist aufgrund eines generischen Fehlers fehlgeschlagen. Bitte überprüfen Sie die Plugin-Einstellungen und versuchen Sie es erneut.'; $string['archive_signing_failed_no_artifact'] = 'Keine gültige Archivdatei gefunden'; -$string['archive_signing_failed_tsp_disabled'] = 'Signierung global ist deaktiviert'; +$string['archive_signing_failed_no_artifact_with_jobid'] = 'Signierung des Testarchivs des Archivierungsauftrags mit der ID {$a} ist fehlgeschlagen. Keine gültige Archivdatei gefunden.'; +$string['archive_signing_failed_tsp_disabled'] = 'Signierung ist global deaktiviert'; $string['sign_archive'] = 'Testarchiv jetzt signieren'; $string['sign_archive_warning'] = 'Sind Sie sicher, dass Sie dieses Testarchiv jetzt signieren möchten?'; $string['signed_on'] = 'Signiert am'; @@ -154,42 +197,42 @@ $string['tsp_query_filename'] = 'query.tsq'; $string['tsp_reply_filename'] = 'reply.tsr'; -// TimeStampProtocolClient -$string['tsp_client_error_content_type'] = 'TSP Server hat einen unerwarteten Content-Type {$a} zurückgegeben'; -$string['tsp_client_error_curl'] = 'Fehler beim senden des TSP Requests: {$a}'; -$string['tsp_client_error_http_code'] = 'TSP Server hat unerwarteten HTTP Statuscode {$a} zurückgegeben'; +// TimeStampProtocolClient. +$string['tsp_client_error_content_type'] = 'TSP-Server hat einen unerwarteten Content-Type {$a} zurückgegeben'; +$string['tsp_client_error_curl'] = 'Fehler beim senden des TSP-Requests: {$a}'; +$string['tsp_client_error_http_code'] = 'TSP-Server hat einen unerwarteten HTTP Statuscode {$a} zurückgegeben'; -// Settings +// Settings. +$string['setting_autoconfigure'] = 'Automatische Konfiguration'; $string['setting_header_archive_worker'] = 'Archive Worker Service'; -$string['setting_header_archive_worker_desc'] = 'Konfiguration des Archive Worker Services und des Moodle Webservices.'; -$string['setting_header_docs_desc'] = 'Dieses Plugin archiviert Testversuche als PDF- und HTML-Dateien zur langfristigen Speicherung unabhängig von Moodle. Es erfordert die Installation eines separaten Archive Worker Services um korrekt zu funktionieren. Die Dokumentation enthält alle notwendigen Informationen und Installationsanweisungen.'; -$string['setting_header_job_presets'] = 'Archiv-Vorgaben'; -$string['setting_header_job_presets_desc'] = 'Systemweite Vorgaben für die Erstellung von Testarchiven. Nutzende können diese Werte bei der Erstellung eines neuen Testarchivs individuell anpassen. Jede einzelne Einstellung kann jedoch auch gesperrt werden, um zu verhindern, dass Nutzende sie verändern. Dies kann nützlich sein, um organisationsweite Archivierungsrichtlinien durchzusetzen.'; +$string['setting_header_archive_worker_desc'] = 'Konfiguration des Archive Worker Services sowie des Moodle Webservices.'; +$string['setting_header_docs_desc'] = 'Dieses Plugin archiviert Testversuche als PDF- und HTML-Dateien zur langfristigen Speicherung unabhängig von Moodle. Es erfordert die Installation eines separaten Archive Worker Services um korrekt zu funktionieren. Die Dokumentation enthält alle notwendigen Informationen und Installationsanweisungen.'; +$string['setting_header_job_presets'] = 'Archivierungs-Vorgaben'; +$string['setting_header_job_presets_desc'] = 'Systemweite Vorgaben für die Erstellung von Testarchiven. Hinterlegte Standardwerte können bei der Erstellung eines neuen Testarchivs individuell anpassen. Jede einzelne Einstellung kann jedoch auch gesperrt werden um zu verhindern, dass Manager / Trainer diese verändern können. Dies kann nützlich sein, um organisationsweite Archivierungsrichtlinien durchzusetzen.'; $string['setting_header_tsp'] = 'Signierung von Testarchiven'; $string['setting_header_tsp_desc'] = 'Testarchive und der Zeitpunkt ihrer Erstellung können von einer vertrauenswürdigen Zertifizierungsstelle mithilfe des Time-Stamp Protocol (TSP) gemäß RFC 3161 digital signiert werden. Diese Signaturen können verwendet werden, um die Datenintegrität und den Zeitpunkt der Archivierung zu einem späteren Zeitpunkt kryptografisch nachzuweisen. Testarchive können automatisch bei der Erstellung oder nachträglich manuell signiert werden.'; $string['setting_internal_wwwroot'] = 'Eigene Moodle Basis-URL'; $string['setting_internal_wwwroot_desc'] = 'Überschreibt die Moodle Basis-URL ($CFG->wwwroot) in den erzeugten Versuchs-Berichten. Dies kann nützlich sein, wenn der Archive Worker Service innerhalb eines privaten Netzwerks (z.B. Docker) läuft und er über das private Netzwerk auf Moodle zugreifen soll.
    Beispiel: http://moodle/'; $string['setting_job_timeout_min'] = 'Auftrags Zeitlimit (Minuten)'; -$string['setting_job_timeout_min_desc'] = 'The number of minutes a single archive job is allowed to run before it is aborted by Moodle. Job web service access tokens become invalid after this timeout.'; -$string['setting_job_timeout_min_desc'] = 'Die maximale Laufzeit eines einzelnen Archivierungsauftrags in Minuten, bevor er durch Moodle abgebrochen wird. Das Webservice Zugriffstoken des Auftrags wird nach diesem Zeitlimit invalidiert.'; +$string['setting_job_timeout_min_desc'] = 'Die maximale Laufzeit eines einzelnen Archivierungsauftrags in Minuten, bevor er durch Moodle abgebrochen wird. Das Webservice Zugriffstoken des Auftrags wird nach diesem Zeitlimit invalidiert.
    Hinweis: Dieses Zeitlimit kann das im Archive Worker Service konfigurierte Zeitlimit nicht überschreiten. Das kürzere Zeitlimit hat stets Vorrang.'; $string['setting_tsp_automatic_signing'] = 'Testarchive automatisch signieren'; $string['setting_tsp_automatic_signing_desc'] = 'Testarchive automatisch bei der Erstellung signieren.'; $string['setting_tsp_enable'] = 'Signierung aktivieren'; $string['setting_tsp_enable_desc'] = 'Erlaubt die Signierung von Testarchiven mithilfe des Time-Stamp Protocols (TSP). Wenn diese Option deaktiviert ist können Testarchive weder manuell noch automatisch signiert werden.'; -$string['setting_tsp_server_url'] = 'TSP server URL'; +$string['setting_tsp_server_url'] = 'TSP-Server URL'; $string['setting_tsp_server_url_desc'] = 'URL des Time-Stamp Protocol (TSP) Servers, der für die Signierung von Testarchiven genutzt wird.
    Beispiele: https://freetsa.org/tsr, https://zeitstempel.dfn.de, http://timestamp.digicert.com'; -$string['setting_webservice_desc'] = 'Der externe Service, welcher alle quiz_archiver_* Funktionen ausführen darf. Er muss ebenfalls die Berechtigung haben, Dateien hoch- und herunterzuladen.'; +$string['setting_webservice_desc'] = 'Der externe Service (Webservice), welcher alle quiz_archiver_* Funktionen ausführen darf. Er muss ebenfalls die Berechtigung haben, Dateien hoch- und herunterzuladen.'; $string['setting_webservice_userid'] = 'Webservice Nutzer-ID'; -$string['setting_webservice_userid_desc'] = 'User-ID des Moodle Nutzers, der vom Archive Worker Service genutzt wird, um auf Testdaten zuzugreifen. Er muss alle Berechtigungen besitzen, die in der Dokumentation aufgelistet sind, um korrekt zu funktionieren. Aus Sicherheitsgründen sollte dies ein dedizierter Nutzer ohne globale Administrationsrechte sein.'; +$string['setting_webservice_userid_desc'] = 'User-ID des Moodle Nutzers, der vom Archive Worker Service genutzt wird, um auf Testdaten zuzugreifen. Er muss alle Berechtigungen besitzen, die in der Dokumentation aufgelistet sind, um korrekt zu funktionieren. Aus Sicherheitsgründen sollte dies ein dedizierter Nutzer ohne globale Administrationsrechte sein.'; $string['setting_worker_url'] = 'Archive Worker URL'; -$string['setting_worker_url_desc'] = 'URL des Archive Worker Services, der für die Ausführung von Archivierungsaufträgen genutzt wird.
    Beispiel: http://127.0.0.1:8080 oder http://moodle-quiz-archive-worker:8080'; +$string['setting_worker_url_desc'] = 'URL des Archive Worker Services, der für die Ausführung von Archivierungsaufträgen genutzt wird. Wenn Sie den Quiz Archiver lediglich ausprobieren wollen, können Sie vorerst auch den kostenfreien öffentlichen Archive Worker Service nutzen.
    Beispiel: http://127.0.0.1:8080 oder http://moodle-quiz-archive-worker:8080'; -// Errors +// Errors. $string['error_worker_connection_failed'] = 'Verbindung zum Archive Worker fehlgeschlagen.'; $string['error_worker_reported_error'] = 'Der Archive Worker hat einen Fehler gemeldet: {$a}'; -$string['error_worker_unknown'] = 'Ein unbekannter Fehler ist beim Senden des Auftrags zum Archive Worker aufgetreten.'; +$string['error_worker_unknown'] = 'Beim Senden des Auftrags zum Archive Worker ist ein unbekannter Fehler aufgetreten.'; -// Privacy +// Privacy. $string['privacy:metadata:core_files'] = 'Das Quiz Archiver Plugin speichert erstellte Testarchive im Moodle Dateisystem.'; $string['privacy:metadata:quiz_archiver_jobs'] = 'Metadaten über erstellte Testarchive.'; $string['privacy:metadata:quiz_archiver_jobs:courseid'] = 'ID des Kurses der zu einem Testarchiv gehört.'; @@ -207,11 +250,28 @@ $string['privacy:metadata:quiz_archiver_tsp:timestampquery'] = 'Die TimestampQuery, der an den TSP Server gesendet wurde.'; $string['privacy:metadata:quiz_archiver_tsp:timestampreply'] = 'Die TimestampReply, die vom TSP Server empfangen wurde.'; -// Tasks +// Tasks. $string['task_cleanup_temp_files'] = 'Bereinigen temporärer Dateien'; -$string['task_cleanup_temp_files_start'] = 'Räume temporäre Dateien auf ...'; +$string['task_cleanup_temp_files_start'] = 'Bereinige temporäre Dateien ...'; $string['task_cleanup_temp_files_report'] = '{$a} temporäre Dateien gelöscht.'; -$string['task_autodelete_job_artifacts'] = 'Delete expired quiz archives'; $string['task_autodelete_job_artifacts'] = 'Löschen abgelaufener Testarchive'; $string['task_autodelete_job_artifacts_start'] = 'Lösche abgelaufene Testarchive ...'; $string['task_autodelete_job_artifacts_report'] = '{$a} Testarchive gelöscht.'; + +// Autoinstall. +$string['autoinstall_already_configured'] = 'Plugin ist bereits konfiguriert'; +$string['autoinstall_already_configured_long'] = 'Das Quiz Archiver Plugin ist bereits konfiguriert. Eine erneute automatische Konfiguration ist nicht möglich.'; +$string['autoinstall_cancelled'] = 'Die automatische Konfiguration des Quiz Archiver Plugins wurde abgebrochen. Es wurden keine Einstellungen verändert.'; +$string['autoinstall_explanation'] = 'Das Quiz Archiver Plugin erfordert anfangs einige Konfigurationsschritte, um zu funktionieren (siehe Konfiguration). Sie können diese Einstellungen entweder manuell vornehmen, oder die automatische Konfigurationsfunktion verwenden um alle Moodle-bezogenen Einstellungen zu setzen.'; +$string['autoinstall_explanation_details'] = 'Die automatische Konfiguration übernimmt die folgenden Schritte:
    • Setzen aller Plugin-Einstellungen auf ihre Standardwerte
    • Aktivieren von Webservices und dem REST-Protokoll
    • Erstellen einer Quiz Archiver Service Rolle und eines entsprechenden Nutzers
    • Erstellen eines neuen Webservices mit allen erforderlichen Webservice-Funktionen
    • Autorisieren des Nutzers zur Nutzung des Webservices
    '; +$string['autoinstall_failure'] = 'Die automatische Konfiguration des Quiz Archiver Plugins ist fehlgeschlagen.'; +$string['autoinstall_plugin'] = 'Quiz Archiver: Automatische Konfiguration'; +$string['autoinstall_started'] = 'Automatische Konfiguration gestartet ...'; +$string['autoinstall_start_now'] = 'Automatische Konfiguration jetzt starten'; +$string['autoinstall_success'] = 'Die automatische Konfiguration des Quiz Archiver Plugins wurde erfolgreich abgeschlossen.'; +$string['autoinstall_rolename'] = 'Rollenname'; +$string['autoinstall_rolename_help'] = 'Name der Rolle, die für den Quiz Archiver Service Nutzer erstellt wird.'; +$string['autoinstall_username'] = 'Nutzername'; +$string['autoinstall_username_help'] = 'Name des Nutzerkontos, das für den Zugriff auf den Quiz Archiver Webservice erstellt wird.'; +$string['autoinstall_wsname'] = 'Webservicename'; +$string['autoinstall_wsname_help'] = 'Name des Webservices, der für den Quiz Archive Worker erstellt wird.'; diff --git a/lang/en/quiz_archiver.php b/lang/en/quiz_archiver.php index 7385834..09ea454 100644 --- a/lang/en/quiz_archiver.php +++ b/lang/en/quiz_archiver.php @@ -22,25 +22,31 @@ * @copyright 2024 Niels Gandraß * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +// @codingStandardsIgnoreFile $string['pluginname'] = 'Quiz Archiver'; $string['archiver'] = 'Quiz Archiver'; $string['archiverreport'] = 'Quiz Archiver'; $string['checksum'] = 'Checksum'; $string['beta_version_warning'] = 'This plugin is currently in beta stage. Please report any problems and bugs you experience to the site administrator.'; +$string['thanks_for_installing'] = 'Thank you for installing the Quiz Archiver plugin!'; +$string['go_to_plugin_settings'] = 'Go to plugin settings'; +$string['manual_configuration_continue'] = 'To manually configure all plugin settings use the "Continue" button at the bottom of this page.'; -// Capabilities -$string['quiz_archiver:view'] = 'View quiz archiver report page'; +// Capabilities. +$string['quiz_archiver:view'] = 'View quiz archiver page'; $string['quiz_archiver:archive'] = 'Create and delete quiz archives'; $string['quiz_archiver:use_webservice'] = 'Use the quiz archiver webservice (read and write)'; -// General +// General. +$string['a'] = '{$a}'; +$string['progress'] = 'Progress'; $string['quiz_archive'] = 'Quiz archive'; $string['quiz_archive_details'] = 'Quiz archive details'; $string['quiz_archive_not_found'] = 'Quiz archive not found'; $string['quiz_archive_not_ready'] = 'Quiz archive not ready yet'; -// Template: Overview +// Template: Overview. $string['archived'] = 'Archived'; $string['users_with_attempts'] = 'Users with quiz attempts'; $string['archive_autodelete'] = 'Automatic deletion'; @@ -50,12 +56,24 @@ $string['archive_retention_time'] = 'Retention time'; $string['archive_retention_time_help'] = 'The amount of time this quiz archive should be kept before it is automatically deleted. This setting only takes effect if automatic deletion is activated.'; $string['create_quiz_archive'] = 'Create new quiz archive'; -$string['archive_quiz_form_desc'] = 'Trigger the creation of a new quiz archive by submitting this form. This will spawn an asynchronous job which will take some time to complete. You can always check the current status on this page.'; +$string['archive_quiz_form_desc'] = 'Trigger the creation of a new quiz archive by submitting this form. This will spawn an asynchronous job which will take some time to complete. You can always check the current status on this page and download finished archives.'; $string['error_archive_quiz_form_validation_failed'] = 'Form data validation failed. Please correct your input and try again.'; +$string['error_plugin_is_not_configured'] = 'Error: The quiz archiver plugin is not configured yet. Please contact your site administrator.'; +$string['error_quiz_cannot_be_archived_unknown'] = 'This quiz can not be archived due to an unknown error. Please report this problem to the plugin developers.'; $string['export_attempts'] = 'Export quiz attempts'; $string['export_attempts_help'] = 'Quiz attempts will always be exported'; $string['export_attempts_num'] = 'Export quiz attempts ({$a})'; $string['export_attempts_num_help'] = 'Quiz attempts will always be exported'; +$string['export_attempts_image_optimize'] = 'Optimize images'; +$string['export_attempts_image_optimize_help'] = 'If enabled, images inside the quiz attempt reports will compressed and large images will be shrunk with respect to the specified dimensions. Images will only ever be scaled down. This only affects PDF exports. HTML source files will always keep the original image size.'; +$string['export_attempts_image_optimize_group'] = 'Maximum image dimensions'; +$string['export_attempts_image_optimize_group_help'] = 'Maximum dimensions for images inside the quiz attempt reports in pixels (width x height). If an image is larger than the given width or height, it will be scaled down so that it fully fits into the given dimensions while maintaining its aspect ratio. This can be useful to reduce the overall archive size if large images are used within the quiz.'; +$string['export_attempts_image_optimize_height'] = 'Maximum image height'; +$string['export_attempts_image_optimize_height_help'] = 'Maximum height of images inside the quiz attempt reports in pixels. If an images height is larger than the given height, it will be scaled down to the given height while maintaining its aspect ratio.'; +$string['export_attempts_image_optimize_quality'] = 'Image compression'; +$string['export_attempts_image_optimize_quality_help'] = 'Quality of compressed images (0 - 100 %). The higher the quality, the larger the file size. This behaves like JPEG compression intensity. A good default value is 85 %.'; +$string['export_attempts_image_optimize_width'] = 'Maximum image width'; +$string['export_attempts_image_optimize_width_help'] = 'Maximum width of images inside the quiz attempt reports in pixels. If an images width is larger than the given width, it will be scaled down to the given width while maintaining its aspect ratio.'; $string['export_attempts_keep_html_files'] = 'HTML files'; $string['export_attempts_keep_html_files_desc'] = 'Keep HTML source files'; $string['export_attempts_keep_html_files_help'] = 'Save HTML source files in addition to the generated PDFs during the export process. This can be useful if you want to access the raw HTML DOM the PDFs were generated from. Disabling this option can significantly reduce the archive size.'; @@ -66,27 +84,31 @@ $string['export_quiz_backup'] = 'Export Moodle quiz backup (.mbz)'; $string['export_quiz_backup_help'] = 'This will export a Moodle quiz backup (.mbz) including questions used inside this quiz. This can be useful if you want to import this quiz independent of this course into another Moodle instance.'; $string['export_report_section_header'] = 'Include quiz header'; -$string['export_report_section_header_help'] = 'Display quiz metadata (e.g., user, time taken, grade, ...) inside the report.'; +$string['export_report_section_header_help'] = 'Display quiz metadata (e.g., user, time taken, grade, ...) inside the attempt report.'; $string['export_report_section_question'] = 'Include questions'; -$string['export_report_section_question_help'] = 'Display all questions that are part of this attempt inside the report.'; +$string['export_report_section_question_help'] = 'Display all questions that are part of this attempt inside the attempt report.'; $string['export_report_section_rightanswer'] = 'Include correct answers'; -$string['export_report_section_rightanswer_help'] = 'Display the correct answers for each question inside the report.'; +$string['export_report_section_rightanswer_help'] = 'Display the correct answers for each question inside the attempt report.'; $string['export_report_section_quiz_feedback'] = 'Include overall quiz feedback'; -$string['export_report_section_quiz_feedback_help'] = 'Display the overall quiz feedback inside the report header.'; +$string['export_report_section_quiz_feedback_help'] = 'Display the overall quiz feedback inside the attempt report header.'; $string['export_report_section_question_feedback'] = 'Include individual question feedback'; -$string['export_report_section_question_feedback_help'] = 'Display the individual feedback for each question inside the report.'; +$string['export_report_section_question_feedback_help'] = 'Display the individual feedback for each question inside the attempt report.'; $string['export_report_section_general_feedback'] = 'Include general question feedback'; -$string['export_report_section_general_feedback_help'] = 'Display the general feedback for each question inside the report.'; +$string['export_report_section_general_feedback_help'] = 'Display the general feedback for each question inside the attempt report.'; $string['export_report_section_history'] = 'Include answer history'; -$string['export_report_section_history_help'] = 'Display the answer history for each question inside the report.'; +$string['export_report_section_history_help'] = 'Display the answer history for each question inside the attempt report.'; $string['export_report_section_attachments'] = 'Include file attachments'; $string['export_report_section_attachments_help'] = 'Include all file attachments (e.g., essay file submissions) inside the archive. Warning: This can significantly increase the archive size.'; $string['job_overview'] = 'Archives'; +$string['last_updated'] = 'Last updated'; $string['num_attempts'] = 'Number of attempts'; -// Job creation form: Filename pattern +// Job creation form: Filename pattern. $string['archive_filename_pattern'] = 'Archive name'; $string['archive_filename_pattern_help'] = 'Name of the generated quiz archive. Variables must follow the ${variablename} pattern. The file extension will be added automatically.

    Available variables:
      {$a->variables}
    Forbidden characters: {$a->forbiddenchars}'; +// TODO (MDL-0): Remove the following 2 lines after deprecation of Moodle 4.1 (LTS) on 08-12-2025. +$string['archive_filename_pattern_moodle42'] = 'Archive name'; +$string['archive_filename_pattern_moodle42_help'] = 'Name of the generated quiz archive. Variables must follow the ${variablename} pattern. The file extension will be added automatically.

    Available variables:
    • ${courseid}: Course ID
    • ${coursename}: Course name
    • ${courseshortname}: Course short name
    • ${cmid}: Course module ID
    • ${quizid}: Quiz ID
    • ${quizname}: Quiz name
    • ${date}: Current date (YYYY-MM-DD)
    • ${time}: Current time (HH-MM-SS)
    • ${timestamp}: Current unix timestamp
    Forbidden characters: \/.:;*?!"<>|'; $string['archive_filename_pattern_variable_courseid'] = 'Course ID'; $string['archive_filename_pattern_variable_coursename'] = 'Course name'; $string['archive_filename_pattern_variable_courseshortname'] = 'Course short name'; @@ -97,8 +119,11 @@ $string['archive_filename_pattern_variable_time'] = 'Current time (HH-MM-SS)'; $string['archive_filename_pattern_variable_timestamp'] = 'Current unix timestamp'; $string['error_invalid_archive_filename_pattern'] = 'Invalid archive filename pattern. Please correct your input and try again.'; -$string['export_attempts_filename_pattern'] = 'Report name'; +$string['export_attempts_filename_pattern'] = 'Attempt name'; $string['export_attempts_filename_pattern_help'] = 'Name of the generated quiz attempt reports (PDF files). Variables must follow the ${variablename} pattern. The file extension will be added automatically.

    Available variables:
      {$a->variables}
    Forbidden characters: {$a->forbiddenchars}'; +// TODO (MDL-0): Remove the following 2 lines after deprecation of Moodle 4.1 (LTS) on 08-12-2025. +$string['export_attempts_filename_pattern_moodle42'] = 'Attempt name'; +$string['export_attempts_filename_pattern_moodle42_help'] = 'Name of the generated quiz attempt reports (PDF files). Variables must follow the ${variablename} pattern. The file extension will be added automatically.

    Available variables:
    • ${courseid}: Course ID
    • ${coursename}: Course name
    • ${courseshortname}: Course short name
    • ${cmid}: Course module ID
    • ${quizid}: Quiz ID
    • ${quizname}: Quiz name
    • ${attemptid}: Attempt ID
    • ${username}: Student username
    • ${firstname}: Student first name
    • ${lastname}: Student last name
    • ${timestart}: Attempt start unix timestamp
    • ${timefinish}: Attempt finish unix timestamp
    • ${date}: Current date (YYYY-MM-DD)
    • ${time}: Current time (HH-MM-SS)
    • ${timestamp}: Current unix timestamp
    Forbidden characters: \/.:;*?!"<>|'; $string['export_attempts_filename_pattern_variable_courseid'] = 'Course ID'; $string['export_attempts_filename_pattern_variable_coursename'] = 'Course name'; $string['export_attempts_filename_pattern_variable_courseshortname'] = 'Course short name'; @@ -109,6 +134,7 @@ $string['export_attempts_filename_pattern_variable_username'] = 'Student username'; $string['export_attempts_filename_pattern_variable_firstname'] = 'Student first name'; $string['export_attempts_filename_pattern_variable_lastname'] = 'Student last name'; +$string['export_attempts_filename_pattern_variable_idnumber'] = 'Student ID number'; $string['export_attempts_filename_pattern_variable_timestart'] = 'Attempt start unix timestamp'; $string['export_attempts_filename_pattern_variable_timefinish'] = 'Attempt finish unix timestamp'; $string['export_attempts_filename_pattern_variable_date'] = 'Current date (YYYY-MM-DD)'; @@ -116,25 +142,40 @@ $string['export_attempts_filename_pattern_variable_timestamp'] = 'Current unix timestamp'; $string['error_invalid_attempt_filename_pattern'] = 'Invalid attempt report filename pattern. Please correct your input and try again.'; -// Job +// Job. $string['delete_artifact'] = 'Delete quiz archive'; -$string['delete_artifact_warning'] = 'Are you sure that you want to delete this quiz archive and therefore all archived data?. The job metadate will be kept.'; +$string['delete_artifact_success'] = 'Quiz archive for Job with ID {$a} was deleted successfully. The job metadata still exists and can be fully deleted using the "Delete job" button.'; +$string['delete_artifact_warning'] = 'Are you sure that you want to delete this quiz archive including all archived data?. The job metadate will be kept.'; $string['delete_job'] = 'Delete archive job'; +$string['delete_job_success'] = 'Archive job with ID {$a} was deleted successfully.'; $string['delete_job_warning'] = 'Are you sure that you want to delete this archive job including all archived data?'; $string['delete_job_warning_retention'] = 'Attention: This archive job is scheduled for automatic deletion on {$a}. Are you absolutely sure that you want to delete it before its scheduled lifetime expired?'; $string['jobid'] = 'Job ID'; -$string['job_created_successfully'] = 'New archive job created successfully: {$a}'; +$string['job_created_successfully'] = 'New archive job created successfully. Job ID: {$a}'; $string['job_status_UNKNOWN'] = 'Unknown'; +$string['job_status_UNKNOWN_help'] = 'The status of this job is unknown. Please open a bug report if this problem persists.'; $string['job_status_UNINITIALIZED'] = 'Uninitialized'; +$string['job_status_UNINITIALIZED_help'] = 'The job has not been initialized yet.'; $string['job_status_AWAITING_PROCESSING'] = 'Queued'; +$string['job_status_AWAITING_PROCESSING_help'] = 'The job registered by the archive worker service and is waiting to be processed.'; $string['job_status_RUNNING'] = 'Running'; +$string['job_status_RUNNING_help'] = 'The job is currently being processed by the archive worker service. The job progress is updated periodically (default: every 15 seconds).'; +$string['job_status_WAITING_FOR_BACKUP'] = 'Backup wait'; +$string['job_status_WAITING_FOR_BACKUP_help'] = 'The job is waiting for a Moodle backup to be created. This can take some time depending on the size of the course.'; +$string['job_status_FINALIZING'] = 'Finalizing'; +$string['job_status_FINALIZING_help'] = 'The archive worker is finalizing the archive and transfers it to Moodle. This can take some time depending on the size of the archive.'; $string['job_status_FINISHED'] = 'Finished'; +$string['job_status_FINISHED_help'] = 'The job has been successfully completed. The archive is ready for download.'; $string['job_status_FAILED'] = 'Failed'; +$string['job_status_FAILED_help'] = 'The job has failed. Please try again and contact your system administrator if this problem persists.'; $string['job_status_TIMEOUT'] = 'Timeout'; +$string['job_status_TIMEOUT_help'] = 'The job has been aborted due to a timeout. This can happen for very large quizzes. Please contact your system administrator if this problem persists.'; $string['job_status_DELETED'] = 'Deleted'; +$string['job_status_DELETED_help'] = 'The quiz archive and all associated data has been removed. The job metadata still exists and can be fully deleted, if required.'; -// Job details +// Job details. $string['archive_already_signed'] = 'Archive is already signed'; +$string['archive_already_signed_with_jobid'] = 'Quiz archive for job with ID {$a} is already signed.'; $string['archive_autodelete_deleted'] = 'Archive was automatically deleted'; $string['archive_autodelete_in'] = 'Archive will be deleted in {$a}'; $string['archive_autodelete_disabled'] = 'Disabled'; @@ -143,8 +184,11 @@ $string['archive_not_signed'] = 'Archive is unsigned'; $string['archive_signature'] = 'Signature'; $string['archive_signed_successfully'] = 'Archive signed successfully'; +$string['archive_signed_successfully_with_jobid'] = 'Quiz archive for job with ID {$a} was signed successfully.'; $string['archive_signing_failed'] = 'Archive signing failed'; +$string['archive_signing_failed_with_jobid'] = 'Signing the quiz archive for job with ID {$a} failed due to a generic error. Please make sure that TSP archive signing is enabled within the plugin settings.'; $string['archive_signing_failed_no_artifact'] = 'No valid artifact file found'; +$string['archive_signing_failed_no_artifact_with_jobid'] = 'Signing the quiz archive for job with ID {$a} failed. No valid artifact file found.'; $string['archive_signing_failed_tsp_disabled'] = 'TSP signing is disabled globally'; $string['sign_archive'] = 'Sign archive now'; $string['sign_archive_warning'] = 'Are you sure that you want to sign this archive now?'; @@ -153,41 +197,42 @@ $string['tsp_query_filename'] = 'query.tsq'; $string['tsp_reply_filename'] = 'reply.tsr'; -// TimeStampProtocolClient +// TimeStampProtocolClient. $string['tsp_client_error_content_type'] = 'TSP server returned unexpected content type {$a}'; $string['tsp_client_error_curl'] = 'Error while sending TSP request: {$a}'; $string['tsp_client_error_http_code'] = 'TSP server returned HTTP status code {$a}'; -// Settings +// Settings. +$string['setting_autoconfigure'] = 'Automatic configuration'; $string['setting_header_archive_worker'] = 'Archive Worker Service'; $string['setting_header_archive_worker_desc'] = 'Configuration of the archive worker service and the Moodle web service it uses.'; -$string['setting_header_docs_desc'] = 'This plugin archives quiz attempts as PDF and HTML files for long-term storage, independent of Moodle. It requires a separate worker service to be installed for the actual archiving process to work. Please refer to the documentation for more details and setup instructions.'; +$string['setting_header_docs_desc'] = 'This plugin archives quiz attempts as PDF and HTML files for long-term storage, independent of Moodle. It requires a separate worker service to be installed for the actual archiving process to work. Please refer to the documentation for more details and setup instructions.'; $string['setting_header_job_presets'] = 'Archive Presets'; -$string['setting_header_job_presets_desc'] = 'System wide default settings for quiz archive creation. These defaults can be overridden when creating a new quiz archive. However, each individual setting can also be locked to prevent users from changing it. This can be useful when enforcing organization wide archive policies.'; +$string['setting_header_job_presets_desc'] = 'System wide default settings for quiz archive creation. These defaults can be overridden when creating a new quiz archive. However, each individual setting can also be locked to prevent managers / teachers from changing it. This can be useful when enforcing organization wide archive policies.'; $string['setting_header_tsp'] = 'Archive Signing'; $string['setting_header_tsp_desc'] = 'Quiz archives and their creation date can be digitally signed by a trusted authority using the Time-Stamp Protocol (TSP) according to RFC 3161. This can be used to cryptographically prove the integrity and creation date of the archive at a later point in time. Quiz archives can be signed automatically at creation or manually later on.'; $string['setting_internal_wwwroot'] = 'Custom Moodle base URL'; -$string['setting_internal_wwwroot_desc'] = 'Overwrites the default Moodle base URL ($CFG->wwwroot) inside generated reports. This can be useful if you are running the archive worker service inside a private network (e.g., Docker) and want it to access Moodle directly.
    Example: http://moodle/'; +$string['setting_internal_wwwroot_desc'] = 'Overwrites the default Moodle base URL ($CFG->wwwroot) inside generated attempt reports. This can be useful if you are running the archive worker service inside a private network (e.g., Docker) and want it to access Moodle directly.
    Example: http://moodle/'; $string['setting_job_timeout_min'] = 'Job timeout (minutes)'; -$string['setting_job_timeout_min_desc'] = 'The number of minutes a single archive job is allowed to run before it is aborted by Moodle. Job web service access tokens become invalid after this timeout.'; +$string['setting_job_timeout_min_desc'] = 'The number of minutes a single archive job is allowed to run before it is aborted by Moodle. Job web service access tokens become invalid after this timeout.
    Note: This timeout can not exceed the timeout configured within the archive worker service. The shorter timeout always takes precedence.'; $string['setting_tsp_automatic_signing'] = 'Automatically sign quiz archives'; $string['setting_tsp_automatic_signing_desc'] = 'Automatically sign quiz archives when they are created.'; $string['setting_tsp_enable'] = 'Enable quiz archive signing'; $string['setting_tsp_enable_desc'] = 'Allow quiz archives to be signed using the Time-Stamp Protocol (TSP). If this option is disabled, quiz archives can neither be signed manually nor automatically.'; $string['setting_tsp_server_url'] = 'TSP server URL'; $string['setting_tsp_server_url_desc'] = 'URL of the Time-Stamp Protocol (TSP) server to use.
    Examples: https://freetsa.org/tsr, https://zeitstempel.dfn.de, http://timestamp.digicert.com'; -$string['setting_webservice_desc'] = 'The webservice that is allowed to execute all quiz_archiver_* webservice functions. It must also have permission to up- and download files.'; +$string['setting_webservice_desc'] = 'The external service (webservice) that is allowed to execute all quiz_archiver_* webservice functions. It must also have permission to up- and download files.'; $string['setting_webservice_userid'] = 'Web service user-ID'; -$string['setting_webservice_userid_desc'] = 'User-ID of the Moodle user that is used by the archive worker service to access quiz data. It must have all capabilities that are listed in the documentation to work properly. For security reasons, this should be a dedicated user account without full administrative privileges.'; +$string['setting_webservice_userid_desc'] = 'User-ID of the Moodle user that is used by the archive worker service to access quiz data. It must have all capabilities that are listed in the documentation to work properly. For security reasons, this should be a dedicated user account without full administrative privileges.'; $string['setting_worker_url'] = 'Archive worker URL'; -$string['setting_worker_url_desc'] = 'URL of the archive worker service to call for quiz archive task execution.
    Example: http://127.0.0.1:8080 or http://moodle-quiz-archive-worker:8080'; +$string['setting_worker_url_desc'] = 'URL of the archive worker service to call for quiz archive task execution. If you only want to try the Quiz Archiver, you can use the free public demo quiz archive worker service, eliminating the need to set up your own worker service right away.
    Example: http://127.0.0.1:8080 or http://moodle-quiz-archive-worker:8080'; -// Errors +// Errors. $string['error_worker_connection_failed'] = 'Establishing a connection to the archive worker failed.'; $string['error_worker_reported_error'] = 'The archive worker reported an error: {$a}'; $string['error_worker_unknown'] = 'An unknown error occurred while enqueueing the job at the remote archive worker.'; -// Privacy +// Privacy. $string['privacy:metadata:core_files'] = 'The quiz archiver plugin stores created quiz archives inside the Moodle file system.'; $string['privacy:metadata:quiz_archiver_jobs'] = 'Metadata about created quiz archives.'; $string['privacy:metadata:quiz_archiver_jobs:courseid'] = 'The course ID of the course the quiz archive belongs to.'; @@ -205,10 +250,28 @@ $string['privacy:metadata:quiz_archiver_tsp:timestampquery'] = 'The timestamp query that was sent to the TSP server.'; $string['privacy:metadata:quiz_archiver_tsp:timestampreply'] = 'The timestamp reply that was received from the TSP server.'; -// Tasks +// Tasks. $string['task_cleanup_temp_files'] = 'Cleanup temporary files'; $string['task_cleanup_temp_files_start'] = 'Cleaning up expired temporary files ...'; $string['task_cleanup_temp_files_report'] = 'Deleted {$a} temporary files.'; $string['task_autodelete_job_artifacts'] = 'Delete expired quiz archives'; $string['task_autodelete_job_artifacts_start'] = 'Deleting expired quiz archives ...'; $string['task_autodelete_job_artifacts_report'] = 'Deleted {$a} quiz archives.'; + +// Autoinstall. +$string['autoinstall_already_configured'] = 'Plugin is already configured'; +$string['autoinstall_already_configured_long'] = 'The Quiz Archiver plugin is already configured. Automatic configuration is not possible twice.'; +$string['autoinstall_cancelled'] = 'The automatic configuration of the Quiz Archiver Plugin was cancelled. No changes were made.'; +$string['autoinstall_explanation'] = 'The Quiz Archiver plugin requires a few initial configuration steps to work (see Configuration). You can either configure all of these settings manually or use the automatic configuration feature to take care of all Moodle related settings.'; +$string['autoinstall_explanation_details'] = 'The automatic configuration feature will take care of the following steps:
    • Setting all plugin settings to their default values
    • Enabling web services and REST protocol
    • Creating a quiz archiver service role and a corresponding user
    • Creating a new web service with all required webservice functions
    • Authorising the user to use the webservice
    '; +$string['autoinstall_failure'] = 'The automatic configuration of the Quiz Archiver Plugin has failed.'; +$string['autoinstall_plugin'] = 'Quiz Archiver: Automatic configuration'; +$string['autoinstall_started'] = 'Automatic configuration started ...'; +$string['autoinstall_start_now'] = 'Start automatic configuration now'; +$string['autoinstall_success'] = 'The automatic configuration of the Quiz Archiver Plugin was successful.'; +$string['autoinstall_rolename'] = 'Role name'; +$string['autoinstall_rolename_help'] = 'Name of the role that is created for the quiz archiver service user.'; +$string['autoinstall_username'] = 'Username'; +$string['autoinstall_username_help'] = 'Name of the service user that is created to access the quiz archiver webservice.'; +$string['autoinstall_wsname'] = 'Web service name'; +$string['autoinstall_wsname_help'] = 'Name of the webservice that is created for the quiz archive worker.'; diff --git a/lib.php b/lib.php index d2913fa..be610a1 100644 --- a/lib.php +++ b/lib.php @@ -22,7 +22,9 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -defined('MOODLE_INTERNAL') || die(); +// @codingStandardsIgnoreLine +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore + use quiz_archiver\FileManager; @@ -50,17 +52,17 @@ function quiz_archiver_pluginfile($course, $cm, $context, $filearea, $args, $for require_capability('quiz/grading:viewstudentnames', $context); require_capability('quiz/grading:viewidnumber', $context); - // Validate course + // Validate course. if ($args[1] !== $course->id) { send_file_not_found(); } - // Try to serve file + // Try to serve file. $fs = get_file_storage(); $relativepath = implode('/', $args); $fullpath = "/$context->id/".FileManager::COMPONENT_NAME."/$filearea/$relativepath"; - // Catch virtual files + // Catch virtual files. if (FileManager::filearea_is_virtual($filearea)) { try { $fm = new FileManager($args[1], $args[2], $args[3]); @@ -70,7 +72,7 @@ function quiz_archiver_pluginfile($course, $cm, $context, $filearea, $args, $for } } - // Try to serve physical files + // Try to serve physical files. $file = $fs->get_file_by_hash(sha1($fullpath)); if (!$file || $file->is_directory()) { send_file_not_found(); diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..82e4044 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,107 @@ +site_name: Moodle Quiz Archiver Documentation +site_url: https://quizarchiver.gandrass.de/ +repo_url: https://github.com/ngandrass/moodle-quiz_archiver +edit_uri: edit/master/docs/ + +copyright: "Copyright © 2023 - 2024 Niels Gandraß
    License: GNU General Public License v3.0" + +extra: + social: + - icon: simple/moodle + link: https://moodle.org/plugins/quiz_archiver + name: "Moodle Plugin Directory: quiz_archiver" + - icon: fontawesome/brands/github + link: https://github.com/ngandrass/moodle-quiz_archiver + name: "GitHub: moodle-quiz_archiver" + - icon: fontawesome/brands/github + link: https://github.com/ngandrass/moodle-quiz-archive-worker + name: "GitHub: moodle-quiz-archive-worker" + - icon: fontawesome/brands/docker + link: https://hub.docker.com/r/ngandrass/moodle-quiz-archive-worker + - icon: fontawesome/solid/bug + link: https://github.com/ngandrass/moodle-quiz_archiver/issues + name: "Report a Bug" + +# version: +# provider: mike + +plugins: + # - mike + - glightbox: + shadow: true + - search + - social + +markdown_extensions: + - admonition + - attr_list + - footnotes + - md_in_html + - tables + - pymdownx.details + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + - pymdownx.superfences + +theme: + name: material + icon: + repo: fontawesome/brands/github + features: + - content.action.edit + - content.code.copy + - navigation.expand + - navigation.indexes + - navigation.sections + - navigation.tracking + - navigation.top + - search.suggest + palette: + # Palette toggle for light mode + - scheme: default + toggle: + icon: material/brightness-7 + name: Switch to dark mode + + # Palette toggle for dark mode + - scheme: slate + toggle: + icon: material/brightness-4 + name: Switch to light mode + +extra_css: + - css/extra.css + +nav: + - "Introduction": index.md + - "Screenshots": screenshots.md + - "Issues": bugreport.md + - "Changelog": changelog.md + + - "Installation": + - installation/index.md + - "Moodle Plugin": installation/moodleplugin.md + - "Archive Worker Service": installation/archiveworker.md + + - "Configuration": + - configuration/index.md + - "Initial Setup": + - "Automatic Configuration": configuration/initialconfig/automatic.md + - "Manual Configuration": configuration/initialconfig/manual.md + - "Pitfalls": configuration/initialconfig/pitfalls.md + - "Job Presets / Policies": configuration/presets.md + - "Capabilities": configuration/capabilities.md + + - "Usage": + - usage/index.md + - "Creating Quiz Archives": usage/archivingbasic.md + - "Automatic Deletion (GDPR)": usage/automaticdeletion.md + - "Image Optimization": usage/imageoptimization.md + - "Quiz Archive Signing (TSP)": usage/tsp.md + + - "Development": + - development/index.md + - "Reference Course / Test Data": development/testdata.md + - "Unit Tests": development/unittests.md + - "Code Coverage": development/codecoverage.md \ No newline at end of file diff --git a/patch_401_class_renames.php b/patch_401_class_renames.php index d273f78..97d2034 100644 --- a/patch_401_class_renames.php +++ b/patch_401_class_renames.php @@ -24,12 +24,19 @@ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +// @codingStandardsIgnoreLine +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore + + +global $CFG; + if ($CFG->branch <= 401) { - require_once($CFG->dirroot . '/mod/quiz/locallib.php'); + require_once($CFG->dirroot.'/mod/quiz/locallib.php'); + require_once($CFG->dirroot.'/lib/external/externallib.php'); - // Patch renamed classes + // Patch renamed classes. foreach ([ - // External API + // External API. 'external_api' => 'core_external\external_api', 'external_description' => 'core_external\external_description', 'external_files' => 'core_external\files', @@ -43,7 +50,7 @@ 'external_warnings' => 'core_external\external_warnings', 'restricted_context_exception' => 'core_external\restricted_context_exception', - // mod_quiz + // Module: mod_quiz. 'quiz_default_report' => 'mod_quiz\local\reports\report_base', 'quiz_attempt' => 'mod_quiz\quiz_attempt', 'mod_quiz_display_options' => 'mod_quiz\question\display_options', diff --git a/report.php b/report.php index 1708ef4..70a748c 100644 --- a/report.php +++ b/report.php @@ -22,13 +22,17 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -// TODO: Remove after deprecation of Moodle 4.1 (LTS) on 08-12-2025 -require_once($CFG->dirroot.'/mod/quiz/report/archiver/patch_401_class_renames.php'); +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore + + +// TODO (MDL-0): Remove after deprecation of Moodle 4.1 (LTS) on 08-12-2025. +require_once($CFG->dirroot.'/mod/quiz/report/archiver/patch_401_class_renames.php'); // @codeCoverageIgnore use mod_quiz\local\reports\report_base; use quiz_archiver\ArchiveJob; use quiz_archiver\BackupManager; use quiz_archiver\form\artifact_delete_form; +use quiz_archiver\local\autoinstall; use quiz_archiver\local\util; use quiz_archiver\RemoteArchiveWorker; use quiz_archiver\Report; @@ -37,8 +41,6 @@ use quiz_archiver\form\job_sign_form; use quiz_archiver\output\job_overview_table; -defined('MOODLE_INTERNAL') || die(); - /** * The quiz archiver report class. */ @@ -67,6 +69,16 @@ public function __construct() { $this->config = get_config('quiz_archiver'); } + /** + * Determines if the quiz with the given ID can be archived. + * + * @param int $quizid The quiz ID to check. + * @return bool True if the quiz can be archived, false otherwise. + */ + public static function quiz_can_be_archived(int $quizid): bool { + return quiz_has_questions($quizid) && quiz_has_attempts($quizid); + } + /** * Display the report. * @@ -88,243 +100,153 @@ public function display($quiz, $cm, $course): bool { $this->context = context_module::instance($cm->id); require_capability('mod/quiz_archiver:view', $this->context); - // Start output. - $this->print_header_and_tabs($cm, $course, $quiz, 'archiver'); - $tplCtx = [ - 'baseurl' => $this->base_url(), - 'jobOverviewTable' => "", - ]; - - // Handle job delete form - if (optional_param('action', null, PARAM_TEXT) === 'delete_job') { - $job_delete_form = new job_delete_form(); - - if ($job_delete_form->is_cancelled()) { - redirect($this->base_url()); - } - - if ($job_delete_form->is_submitted()) { - // Check permissions. - require_capability('mod/quiz_archiver:delete', $this->context); + // Handle forms before output starts. + $formhtml = $this->handle_posted_forms(); - // Execute deletion - $formdata = $job_delete_form->get_data(); - ArchiveJob::get_by_jobid($formdata->jobid)->delete(); - } else { - $job_delete_form->display(); - return true; - } + // Housekeeping for jobs associated with this quiz. + foreach (ArchiveJob::get_jobs($this->course->id, $this->cm->id, $this->quiz->id) as $job) { + $job->timeout_if_overdue($this->config->job_timeout_min); } - // Handle artifact delete form - if (optional_param('action', null, PARAM_TEXT) === 'delete_artifact') { - $arfifact_delete_form = new artifact_delete_form(); - - if ($arfifact_delete_form->is_cancelled()) { - redirect($this->base_url()); - } - - if ($arfifact_delete_form->is_submitted()) { - // Check permissions. - require_capability('mod/quiz_archiver:delete', $this->context); + // Start output. + $this->print_header_and_tabs($cm, $course, $quiz, 'archiver'); - // Execute deletion - $formdata = $arfifact_delete_form->get_data(); - ArchiveJob::get_by_jobid($formdata->jobid)->delete_artifact(); - } else { - $arfifact_delete_form->display(); - return true; - } + // Check if we need to display a form output and abort the rest of the page rendering. + // If rendering should continue the form must redirect to a new page without POST data + // to avoid re-execution of the form handling logic. + if ($formhtml) { + echo $formhtml; + return true; } - // Handle job sign form - if (optional_param('action', null, PARAM_TEXT) === 'sign_job') { - $job_sign_form = new job_sign_form(); - - if ($job_sign_form->is_cancelled()) { - redirect($this->base_url()); - } - - if ($job_sign_form->is_submitted()) { - // Check permissions. - require_capability('mod/quiz_archiver:create', $this->context); - - // Execute signing - $formdata = $job_sign_form->get_data(); - $tspManager = ArchiveJob::get_by_jobid($formdata->jobid)->TSPManager(); - $jobid_log_str = ' ('.get_string('jobid', 'quiz_archiver').': '.$formdata->jobid.')'; - if ($tspManager->has_tsp_timestamp()) { - $tplCtx['jobInitiationStatusAlert'] = [ - "color" => "danger", - "dismissible" => true, - "message" => get_string('archive_already_signed', 'quiz_archiver').$jobid_log_str, - ]; - } else { - try { - $tspManager->timestamp(); - $tplCtx['jobInitiationStatusAlert'] = [ - "color" => "success", - "dismissible" => true, - "message" => get_string('archive_signed_successfully', 'quiz_archiver').$jobid_log_str, - ]; - } catch (RuntimeException $e) { - $tplCtx['jobInitiationStatusAlert'] = [ - "color" => "danger", - "dismissible" => true, - "message" => get_string('archive_signing_failed_no_artifact', 'quiz_archiver').$jobid_log_str, - ]; - } catch (Exception $e) { - $tplCtx['jobInitiationStatusAlert'] = [ - "color" => "danger", - "dismissible" => true, - "message" => get_string('archive_signing_failed', 'quiz_archiver').': '.$e->getMessage().$jobid_log_str, - ]; - } - } - } else { - $job_sign_form->display(); - return true; - } + // Handle GET-based alerts. + if (optional_param('al', null, PARAM_TEXT) !== null) { + echo $OUTPUT->notification( + get_string( + urldecode(required_param('asid', PARAM_TEXT)), + 'quiz_archiver', + urldecode(optional_param('aa', null, PARAM_TEXT)) + ), + urldecode(required_param('al', PARAM_TEXT)), + optional_param('ac', 0, PARAM_INT) === 1 + ); } - // Determine page to display - if (!quiz_has_questions($quiz->id)) { - $tplCtx['quizMissingSomethingWarning'] = quiz_no_questions_message($quiz, $cm, $this->context); - } else { - if (!quiz_has_attempts($quiz->id)) { - $tplCtx['quizMissingSomethingWarning'] = $OUTPUT->notification( + // Check if this quiz can be archived. + if (!self::quiz_can_be_archived($quiz->id)) { + if (!quiz_has_questions($quiz->id)) { + echo quiz_no_questions_message($quiz, $cm, $this->context); + } else if (!quiz_has_attempts($quiz->id)) { + echo $OUTPUT->notification( get_string('noattempts', 'quiz'), \core\output\notification::NOTIFY_ERROR, false ); + } else { + echo $OUTPUT->notification( + get_string('error_quiz_cannot_be_archived_unknown', 'quiz_archiver'), + \core\output\notification::NOTIFY_ERROR, + false + ); } + return true; } - // Archive quiz form - if (!array_key_exists('quizMissingSomethingWarning', $tplCtx)) { - $archive_quiz_form = new archive_quiz_form( - $this->quiz->name, - count($this->report->get_attempts()) - ); - if ($archive_quiz_form->is_submitted()) { - $job = null; - try { - if (!$archive_quiz_form->is_validated()) { - throw new RuntimeException(get_string('error_archive_quiz_form_validation_failed', 'quiz_archiver')); - } - - $formdata = $archive_quiz_form->get_data(); - $job = $this->initiate_archive_job( - $formdata->export_attempts, - Report::build_report_sections_from_formdata($formdata), - $formdata->export_attempts_keep_html_files, - $formdata->export_attempts_paper_format, - $formdata->export_quiz_backup, - $formdata->export_course_backup, - $formdata->archive_filename_pattern, - $formdata->export_attempts_filename_pattern, - $formdata->archive_autodelete ? $formdata->archive_retention_time : null, - ); - $tplCtx['jobInitiationStatusAlert'] = [ - "color" => "success", - "message" => get_string('job_created_successfully', 'quiz_archiver', $job->get_jobid()), - "returnMessage" => get_string('continue'), - ]; - } catch (RuntimeException $e) { - $tplCtx['jobInitiationStatusAlert'] = [ - "color" => "danger", - "message" => $e->getMessage(), - "returnMessage" => get_string('retry'), - ]; - } + // Quiz archive form. Logic is handled in handle_posted_forms() above. + $archivequizform = new archive_quiz_form( + $this->quiz->name, + count($this->report->get_attempts()) + ); + + // Job overview table. + $jobtbl = new job_overview_table('job_overview_table', $this->course->id, $this->cm->id, $this->quiz->id); + $jobtbl->define_baseurl($this->base_url()); + ob_start(); + $jobtbl->out(10, true); + $jobtblhtml = ob_get_contents(); + ob_end_clean(); + + // Render output. + echo $OUTPUT->render_from_template('quiz_archiver/overview', [ + 'archiveQuizForm' => $archivequizform->render(), + 'baseurl' => $this->base_url(), + 'jobOverviewTable' => $jobtblhtml, + 'jobs' => $this->generate_job_metadata_tplctx(), + 'time' => time(), + ]); - // Do not print job overview table if job creation failed - if ($job == null) { - unset($tplCtx['jobOverviewTable']); - } - } else { - $tplCtx['jobInitiationForm'] = $archive_quiz_form->render(); - } - } + return true; + } - // Job overview table - if (array_key_exists('jobOverviewTable', $tplCtx)) { - // Generate table - $jobtbl = new job_overview_table('job_overview_table', $this->course->id, $this->cm->id, $this->quiz->id); - $jobtbl->define_baseurl($this->base_url()); - ob_start(); - $jobtbl->out(10, true); - $jobtbl_html = ob_get_contents(); - ob_end_clean(); - $tplCtx['jobOverviewTable'] = $jobtbl_html; - - // Prepare job metadata for job detail modals - $tplCtx['jobs'] = array_map(function($jm): array { - // Generate action URLs - $jm['action_urls'] = [ - 'delete_job' => (new moodle_url($this->base_url(), [ - 'id' => optional_param('id', null, PARAM_INT), - 'mode' => 'archiver', - 'action' => 'delete_job', - 'jobid' => $jm['jobid'], - ]))->out(), - 'delete_artifact' => (new moodle_url($this->base_url(), [ - 'id' => optional_param('id', null, PARAM_INT), - 'mode' => 'archiver', - 'action' => 'delete_artifact', - 'jobid' => $jm['jobid'], - ]))->out(), - 'sign_artifact' => (new moodle_url('', [ - 'id' => optional_param('id', null, PARAM_INT), - 'mode' => 'archiver', - 'action' => 'sign_job', - 'jobid' => $jm['jobid'], - ]))->out(), - 'course' => (new moodle_url('/course/view.php', [ - 'id' => $this->course->id, - ]))->out(), - 'quiz' => (new moodle_url('/mod/quiz/view.php', [ - 'id' => $this->cm->id, - ]))->out(), - 'user' => (new moodle_url('/user/profile.php', [ - 'id' => $jm['user']['id'], - ]))->out(), - ]; - - // Inject global TSP settings - $jm['tsp_enabled'] = ($this->config->tsp_enable == true); // Moodle stores checkbox values as '0' and '1'. Mustache interprets '0' as true. - - return [ + /** + * Generates the template context data for all jobs associated with this quiz + * to be displayed inside the job details modal dialogs. + * + * @return array Array of job metadata arrays to be passed to the Mustache template + * @throws coding_exception + * @throws dml_exception + * @throws moodle_exception + */ + protected function generate_job_metadata_tplctx(): array { + return array_map(function($jm): array { + // Generate action URLs. + $jm['action_urls'] = [ + 'delete_job' => (new moodle_url($this->base_url(), [ + 'id' => optional_param('id', null, PARAM_INT), + 'mode' => 'archiver', + 'action' => 'delete_job', 'jobid' => $jm['jobid'], - 'json' => json_encode($jm), - ]; - }, ArchiveJob::get_metadata_for_jobs($this->course->id, $this->cm->id, $this->quiz->id)); - } - - // Housekeeping for jobs associated with this quiz - foreach (ArchiveJob::get_jobs($this->course->id, $this->cm->id, $this->quiz->id) as $job) { - $job->timeout_if_overdue($this->config->job_timeout_min); - } + ]))->out(), + 'delete_artifact' => (new moodle_url($this->base_url(), [ + 'id' => optional_param('id', null, PARAM_INT), + 'mode' => 'archiver', + 'action' => 'delete_artifact', + 'jobid' => $jm['jobid'], + ]))->out(), + 'sign_artifact' => (new moodle_url('', [ + 'id' => optional_param('id', null, PARAM_INT), + 'mode' => 'archiver', + 'action' => 'sign_job', + 'jobid' => $jm['jobid'], + ]))->out(), + 'course' => (new moodle_url('/course/view.php', [ + 'id' => $this->course->id, + ]))->out(), + 'quiz' => (new moodle_url('/mod/quiz/view.php', [ + 'id' => $this->cm->id, + ]))->out(), + 'user' => (new moodle_url('/user/profile.php', [ + 'id' => $jm['user']['id'], + ]))->out(), + ]; - // Render output - echo $OUTPUT->render_from_template('quiz_archiver/overview', $tplCtx); + // Inject global TSP settings. + // Moodle stores checkbox values as '0' and '1'. Mustache interprets '0' as true. + $jm['tsp_enabled'] = ($this->config->tsp_enable == true); - return true; + return [ + 'jobid' => $jm['jobid'], + 'json' => json_encode($jm), + ]; + }, ArchiveJob::get_metadata_for_jobs($this->course->id, $this->cm->id, $this->quiz->id)); } /** * Initiates a new archive job for this quiz * - * @param bool $export_attempts Quiz attempts will be archives if true - * @param array $report_sections Sections to export during attempt report generation - * @param bool $report_keep_html_files If true, HTML files are kept alongside PDFs - * within the created archive - * @param string $paper_format Paper format to use for attempt report generation - * @param bool $export_quiz_backup Complete quiz backup will be archived if true - * @param bool $export_course_backup Complete course backup will be archived if true - * @param string $archive_filename_pattern Filename pattern to use for archive generation - * @param string $attempts_filename_pattern Filename pattern to use for attempt report generation - * @param int|null $retention_seconds If set, the archive will be deleted automatically this many seconds after creation + * @param bool $exportattempts Quiz attempts will be archives if true + * @param array $reportsections Sections to export during attempt report generation + * @param bool $reportkeephtmlfiles If true, HTML files are kept alongside PDFs + * within the created archive + * @param string $paperformat Paper format to use for attempt report generation + * @param bool $exportquizbackup Complete quiz backup will be archived if true + * @param bool $exportcoursebackup Complete course backup will be archived if true + * @param string $archivefilenamepattern Filename pattern to use for archive generation + * @param string $attemptsfilenamepattern Filename pattern to use for attempt report generation + * @param array|null $imageoptimize If set, images in the attempt report will be optimized + * according to the passed array containing 'width', 'height', and 'quality' + * @param int|null $retentionseconds If set, the archive will be deleted automatically this + * many seconds after creation * @return ArchiveJob|null Created ArchiveJob on success * @throws coding_exception Handled by Moodle * @throws dml_exception Handled by Moodle @@ -332,24 +254,30 @@ public function display($quiz, $cm, $course): bool { * @throws RuntimeException Used to signal a soft failure to calling context */ protected function initiate_archive_job( - bool $export_attempts, - array $report_sections, - bool $report_keep_html_files, - string $paper_format, - bool $export_quiz_backup, - bool $export_course_backup, - string $archive_filename_pattern, - string $attempts_filename_pattern, - ?int $retention_seconds = null + bool $exportattempts, + array $reportsections, + bool $reportkeephtmlfiles, + string $paperformat, + bool $exportquizbackup, + bool $exportcoursebackup, + string $archivefilenamepattern, + string $attemptsfilenamepattern, + ?array $imageoptimize = null, + ?int $retentionseconds = null ): ?ArchiveJob { - global $USER; + global $CFG, $USER; // Check permissions. require_capability('mod/quiz_archiver:create', $this->context); - // Create temporary webservice token - if (class_exists('core_external\util')) { - // Moodle 4.2 and above + // Check if webservice is configured properly. + if (autoinstall::plugin_is_unconfigured()) { + throw new \RuntimeException(get_string('error_plugin_is_not_configured', 'quiz_archiver')); + } + + // Create temporary webservice token. + if ($CFG->branch > 401 && class_exists('core_external\util')) { + // Moodle 4.2 and above. $wstoken = core_external\util::generate_token( EXTERNAL_TOKEN_PERMANENT, core_external\util::get_service_by_id($this->config->webservice_id), @@ -359,8 +287,8 @@ protected function initiate_archive_job( 0 ); } else { - // Moodle 4.1 and below - // TODO: Remove after deprecation of Moodle 4.1 (LTS) on 08-12-2025 + // Moodle 4.1 and below. + // TODO (MDL-0): Remove after deprecation of Moodle 4.1 (LTS) on 08-12-2025. $wstoken = external_generate_token( EXTERNAL_TOKEN_PERMANENT, $this->config->webservice_id, @@ -371,84 +299,90 @@ protected function initiate_archive_job( ); } - // Get attempt metadata + // Get attempt metadata. $attempts = $this->report->get_attempts(); - // Prepare task: Export quiz attempts - $task_archive_quiz_attempts = null; - if ($export_attempts) { - $task_archive_quiz_attempts = [ + // Prepare task: Export quiz attempts. + $taskarchivequizattempts = null; + if ($exportattempts) { + $taskarchivequizattempts = [ 'attemptids' => array_values(array_keys($attempts)), 'fetch_metadata' => true, - 'sections' => $report_sections, - 'paper_format' => $paper_format, - 'keep_html_files' => $report_keep_html_files, - 'filename_pattern' => $attempts_filename_pattern, + 'sections' => $reportsections, + 'paper_format' => $paperformat, + 'keep_html_files' => $reportkeephtmlfiles, + 'filename_pattern' => $attemptsfilenamepattern, + 'image_optimize' => $imageoptimize ?? false, ]; } - // Prepare task: Moodle backups - $task_moodle_backups = null; - if ($export_quiz_backup || $export_course_backup) { - $task_moodle_backups = []; + // Prepare task: Moodle backups. + $taskmoodlebackups = null; + if ($exportquizbackup || $exportcoursebackup) { + $taskmoodlebackups = []; - if ($export_quiz_backup) { - $task_moodle_backups[] = BackupManager::initiate_quiz_backup($this->cm->id, $this->config->webservice_userid); + if ($exportquizbackup) { + $taskmoodlebackups[] = BackupManager::initiate_quiz_backup($this->cm->id, $this->config->webservice_userid); } - if ($export_course_backup) { - $task_moodle_backups[] = BackupManager::initiate_course_backup($this->course->id, $this->config->webservice_userid); + if ($exportcoursebackup) { + $taskmoodlebackups[] = BackupManager::initiate_course_backup($this->course->id, $this->config->webservice_userid); } } - // Generate job settings array - $job_settings = []; - $job_settings['num_attempts'] = count($attempts); - $job_settings['export_attempts'] = $export_attempts; - if ($export_attempts) { - foreach ($report_sections as $section_name => $section_value) { - $job_settings["export_report_section_$section_name"] = $section_value; + // Generate job settings array. + $jobsettings = []; + $jobsettings['num_attempts'] = count($attempts); + $jobsettings['export_attempts'] = $exportattempts; + if ($exportattempts) { + foreach ($reportsections as $name => $value) { + $jobsettings["export_report_section_$name"] = $value; } } - $job_settings['export_quiz_backup'] = $export_quiz_backup ? '1' : '0'; - $job_settings['export_course_backup'] = $export_course_backup ? '1' : '0'; - $job_settings['archive_autodelete'] = $retention_seconds ? '1' : '0'; - if ($retention_seconds) { - $job_settings['archive_retention_time'] = util::duration_to_human_readable($retention_seconds); + $jobsettings['export_quiz_backup'] = $exportquizbackup ? '1' : '0'; + $jobsettings['export_course_backup'] = $exportcoursebackup ? '1' : '0'; + $jobsettings['archive_autodelete'] = $retentionseconds ? '1' : '0'; + if ($retentionseconds) { + $jobsettings['archive_retention_time'] = util::duration_to_human_readable($retentionseconds); } - // Request archive worker + // Request archive worker. $worker = new RemoteArchiveWorker(rtrim($this->config->worker_url, '/').'/archive', 10, 20); try { - $job_metadata = $worker->enqueue_archive_job( + $jobmetadata = $worker->enqueue_archive_job( $wstoken, $this->course->id, $this->cm->id, $this->quiz->id, [ - 'archive_filename' => ArchiveJob::generate_archive_filename($this->course, $this->cm, $this->quiz, $archive_filename_pattern), + 'archive_filename' => ArchiveJob::generate_archive_filename( + $this->course, + $this->cm, + $this->quiz, + $archivefilenamepattern + ), ], - $task_archive_quiz_attempts, - $task_moodle_backups, + $taskarchivequizattempts, + $taskmoodlebackups, ); - // Persist job in database + // Persist job in database. $job = ArchiveJob::create( - $job_metadata->jobid, + $jobmetadata->jobid, $this->course->id, $this->cm->id, $this->quiz->id, $USER->id, - $retention_seconds, + $retentionseconds, $wstoken, $attempts, - $job_settings, - $job_metadata->status + $jobsettings, + $jobmetadata->status ); - // Link all temporary files to be created, if present - if ($task_moodle_backups) { - foreach ($task_moodle_backups as $task) { + // Link all temporary files to be created, if present. + if ($taskmoodlebackups) { + foreach ($taskmoodlebackups as $task) { $job->link_temporary_file($task->pathnamehash); } } @@ -465,14 +399,214 @@ protected function initiate_archive_job( return $job; } + /** + * Handles submitted forms. + * + * @return string|null HTML form rendering to display, if required. + * + * @throws coding_exception + * @throws dml_exception + * @throws moodle_exception + * @throws required_capability_exception + */ + protected function handle_posted_forms(): ?string { + // Job delete form. + if (optional_param('action', null, PARAM_TEXT) === 'delete_job') { + $jobdeleteform = new job_delete_form(); + + if ($jobdeleteform->is_cancelled()) { + redirect($this->base_url()); + } + + if ($jobdeleteform->is_submitted()) { + // Check permissions. + require_capability('mod/quiz_archiver:delete', $this->context); + + // Execute deletion. + $formdata = $jobdeleteform->get_data(); + ArchiveJob::get_by_jobid($formdata->jobid)->delete(); + + redirect($this->base_url_with_alert( + \core\output\notification::NOTIFY_SUCCESS, + true, + 'delete_job_success', + $formdata->jobid + )); + } else { + return $jobdeleteform->render(); + } + } + + // Artifact delete form. + if (optional_param('action', null, PARAM_TEXT) === 'delete_artifact') { + $artifactdeleteform = new artifact_delete_form(); + + if ($artifactdeleteform->is_cancelled()) { + redirect($this->base_url()); + } + + if ($artifactdeleteform->is_submitted()) { + // Check permissions. + require_capability('mod/quiz_archiver:delete', $this->context); + + // Execute deletion. + $formdata = $artifactdeleteform->get_data(); + ArchiveJob::get_by_jobid($formdata->jobid)->delete_artifact(); + + redirect($this->base_url_with_alert( + \core\output\notification::NOTIFY_SUCCESS, + true, + 'delete_artifact_success', + $formdata->jobid + )); + } else { + return $artifactdeleteform->render(); + } + } + + // Job sign form. + if (optional_param('action', null, PARAM_TEXT) === 'sign_job') { + $jobsignform = new job_sign_form(); + + if ($jobsignform->is_cancelled()) { + redirect($this->base_url()); + } + + if ($jobsignform->is_submitted()) { + // Check permissions. + require_capability('mod/quiz_archiver:create', $this->context); + + // Execute signing. + $formdata = $jobsignform->get_data(); + $tspmanager = ArchiveJob::get_by_jobid($formdata->jobid)->tspmanager(); + $jobidlogstr = ' ('.get_string('jobid', 'quiz_archiver').': '.$formdata->jobid.')'; + if ($tspmanager->has_tsp_timestamp()) { + redirect($this->base_url_with_alert( + \core\output\notification::NOTIFY_ERROR, + true, + 'archive_already_signed_with_jobid', + $formdata->jobid + )); + } else { + try { + $tspmanager->timestamp(); + redirect($this->base_url_with_alert( + \core\output\notification::NOTIFY_SUCCESS, + true, + 'archive_signed_successfully_with_jobid', + $formdata->jobid + )); + } catch (RuntimeException $e) { + redirect($this->base_url_with_alert( + \core\output\notification::NOTIFY_ERROR, + true, + 'archive_signing_failed_no_artifact_with_jobid', + $formdata->jobid + )); + } catch (Exception $e) { + redirect($this->base_url_with_alert( + \core\output\notification::NOTIFY_ERROR, + true, + 'archive_signing_failed_with_jobid', + $formdata->jobid + )); + } + } + } else { + return $jobsignform->render(); + } + } + + // Archive quiz form. + if (self::quiz_can_be_archived($this->quiz->id)) { + $archivequizform = new archive_quiz_form( + $this->quiz->name, + count($this->report->get_attempts()) + ); + + if ($archivequizform->is_submitted()) { + $job = null; + try { + if (!$archivequizform->is_validated()) { + throw new RuntimeException(get_string('error_archive_quiz_form_validation_failed', 'quiz_archiver')); + } + + $formdata = $archivequizform->get_data(); + $job = $this->initiate_archive_job( + $formdata->export_attempts, + Report::build_report_sections_from_formdata($formdata), + $formdata->export_attempts_keep_html_files, + $formdata->export_attempts_paper_format, + $formdata->export_quiz_backup, + $formdata->export_course_backup, + $formdata->archive_filename_pattern, + $formdata->export_attempts_filename_pattern, + $formdata->export_attempts_image_optimize ? [ + 'width' => (int) $formdata->export_attempts_image_optimize_width, + 'height' => (int) $formdata->export_attempts_image_optimize_height, + 'quality' => (int) $formdata->export_attempts_image_optimize_quality, + ] : null, + $formdata->archive_autodelete ? $formdata->archive_retention_time : null, + ); + redirect($this->base_url_with_alert( + \core\output\notification::NOTIFY_SUCCESS, + true, + 'job_created_successfully', + $job->get_jobid() + )); + } catch (RuntimeException $e) { + redirect($this->base_url_with_alert( + \core\output\notification::NOTIFY_ERROR, + true, + 'a', + $e->getMessage() + )); + } + } else { + // This form is rendered on the main report page if no other form requires rendering. + return null; + } + } + + return null; + } + /** * Get the URL of the front page of the report that lists all the questions. * - * @return moodle_url the URL + * @return moodle_url The URL * @throws moodle_exception */ protected function base_url(): moodle_url { return new moodle_url('/mod/quiz/report.php', ['id' => $this->cm->id, 'mode' => 'archiver']); } + /** + * Get the URL of the front page of the report with GET params to display an alert message. + * + * @param string $level Alert level \core\output\notification::NOTIFY_* + * @param bool $closebutton If true, a close button is displayed in the alert message + * @param string $stridentifier Identifier of the alert message string from language file + * @param string|null $additional Additional context ($a) that is passed to get_string() + * + * @return moodle_url The URL including the alert message params + * @throws moodle_exception + */ + protected function base_url_with_alert( + string $level, + bool $closebutton, + string $stridentifier, + ?string $additional = null + ): moodle_url { + $url = $this->base_url(); + $url->param('al', urlencode($level)); + $url->param('ac', $closebutton ? '1' : '0'); + $url->param('asid', urlencode($stridentifier)); + if ($additional !== null) { + $url->param('aa', urlencode($additional)); + } + + return $url; + } + } diff --git a/res/backup-moodle2-course-qa-ref.mbz b/res/backup-moodle2-course-qa-ref.mbz index e82f38b..8d90597 100644 Binary files a/res/backup-moodle2-course-qa-ref.mbz and b/res/backup-moodle2-course-qa-ref.mbz differ diff --git a/settings.php b/settings.php index fd03c01..2ebf816 100644 --- a/settings.php +++ b/settings.php @@ -23,14 +23,18 @@ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore + + +require_once(__DIR__ . '/classes/local/autoinstall.php'); + use quiz_archiver\ArchiveJob; use quiz_archiver\local\admin\setting\admin_setting_archive_filename_pattern; use quiz_archiver\local\admin\setting\admin_setting_attempt_filename_pattern; use quiz_archiver\local\admin\setting\admin_setting_configcheckbox_alwaystrue; +use quiz_archiver\local\autoinstall; use quiz_archiver\Report; -defined('MOODLE_INTERNAL') || die(); - global $DB; if ($hassiteconfig) { @@ -38,18 +42,34 @@ // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedIf if ($ADMIN->fulltree) { - // Descriptive text + // Descriptive text. $settings->add(new admin_setting_heading('quiz_archiver/header_docs', null, get_string('setting_header_docs_desc', 'quiz_archiver') )); - // Generic settings + // Autoinstall. + if (autoinstall::plugin_is_unconfigured()) { + // @codingStandardsIgnoreStart + $autoinstallurl = new moodle_url('/mod/quiz/report/archiver/adminui/autoinstall.php'); + $autoinstalldesc = "".get_string('autoinstall_start_now', 'quiz_archiver').""; + $autoinstalldesc .= "

    ".get_string('autoinstall_explanation', 'quiz_archiver')."

    "; + // @codingStandardsIgnoreEnd + } else { + $autoinstalldesc = get_string('autoinstall_already_configured', 'quiz_archiver'); + } + $settings->add(new admin_setting_description('quiz_archiver/autoinstall', + get_string('setting_autoconfigure', 'quiz_archiver'), + $autoinstalldesc + )); + + // Generic settings. $settings->add(new admin_setting_heading('quiz_archiver/header_archive_worker', get_string('setting_header_archive_worker', 'quiz_archiver'), get_string('setting_header_archive_worker_desc', 'quiz_archiver') )); + // Worker URL. $settings->add(new admin_setting_configtext('quiz_archiver/worker_url', get_string('setting_worker_url', 'quiz_archiver'), get_string('setting_worker_url_desc', 'quiz_archiver'), @@ -57,6 +77,7 @@ PARAM_TEXT )); + // Webservice. $settings->add(new admin_setting_configselect('quiz_archiver/webservice_id', get_string('webservice', 'webservice'), get_string('setting_webservice_desc', 'quiz_archiver'), @@ -64,6 +85,7 @@ [-1 => ''] + $DB->get_records_menu('external_services', null, 'name ASC', 'id, name') )); + // Webservice user. $settings->add(new admin_setting_configtext('quiz_archiver/webservice_userid', get_string('setting_webservice_userid', 'quiz_archiver'), get_string('setting_webservice_userid_desc', 'quiz_archiver'), @@ -71,13 +93,15 @@ PARAM_INT )); + // Job timeout. $settings->add(new admin_setting_configtext('quiz_archiver/job_timeout_min', get_string('setting_job_timeout_min', 'quiz_archiver'), get_string('setting_job_timeout_min_desc', 'quiz_archiver'), - '120', + '60', PARAM_INT )); + // Custom Moodle base URL. $settings->add(new admin_setting_configtext('quiz_archiver/internal_wwwroot', get_string('setting_internal_wwwroot', 'quiz_archiver'), get_string('setting_internal_wwwroot_desc', 'quiz_archiver'), @@ -85,18 +109,20 @@ PARAM_TEXT )); - // Job Presets + // Job Presets. $settings->add(new admin_setting_heading('quiz_archiver/header_job_presets', get_string('setting_header_job_presets', 'quiz_archiver'), get_string('setting_header_job_presets_desc', 'quiz_archiver'), )); + // Export Attempts. $settings->add(new admin_setting_configcheckbox_alwaystrue('quiz_archiver/job_preset_export_attempts', get_string('export_attempts', 'quiz_archiver'), get_string('export_attempts_help', 'quiz_archiver'), '1', )); + // Attempt report sections. foreach (Report::SECTIONS as $section) { $set = new admin_setting_configcheckbox('quiz_archiver/job_preset_export_report_section_'.$section, get_string('export_report_section_'.$section, 'quiz_archiver'), @@ -112,6 +138,7 @@ $settings->add($set); } + // Export Quiz Backup. $set = new admin_setting_configcheckbox('quiz_archiver/job_preset_export_quiz_backup', get_string('export_quiz_backup', 'quiz_archiver'), get_string('export_quiz_backup_help', 'quiz_archiver'), @@ -120,6 +147,7 @@ $set->set_locked_flag_options(admin_setting_flag::ENABLED, false); $settings->add($set); + // Export Course Backup. $set = new admin_setting_configcheckbox('quiz_archiver/job_preset_export_course_backup', get_string('export_course_backup', 'quiz_archiver'), get_string('export_course_backup_help', 'quiz_archiver'), @@ -128,6 +156,7 @@ $set->set_locked_flag_options(admin_setting_flag::ENABLED, false); $settings->add($set); + // Export paper format. $set = new admin_setting_configselect('quiz_archiver/job_preset_export_attempts_paper_format', get_string('export_attempts_paper_format', 'quiz_archiver'), get_string('export_attempts_paper_format_help', 'quiz_archiver'), @@ -137,20 +166,15 @@ $set->set_locked_flag_options(admin_setting_flag::ENABLED, false); $settings->add($set); - $set = new admin_setting_configcheckbox('quiz_archiver/job_preset_export_attempts_keep_html_files', - get_string('export_attempts_keep_html_files', 'quiz_archiver'), - get_string('export_attempts_keep_html_files_help', 'quiz_archiver'), - '0', - ); - $set->set_locked_flag_options(admin_setting_flag::ENABLED, false); - $settings->add($set); - + // Archive filename pattern. $set = new admin_setting_archive_filename_pattern('quiz_archiver/job_preset_archive_filename_pattern', get_string('archive_filename_pattern', 'quiz_archiver'), get_string('archive_filename_pattern_help', 'quiz_archiver', [ 'variables' => array_reduce( ArchiveJob::ARCHIVE_FILENAME_PATTERN_VARIABLES, - fn ($res, $varname) => $res . "
  • \${".$varname."}: ".get_string('export_attempts_filename_pattern_variable_'.$varname, 'quiz_archiver')."
  • " + fn ($res, $varname) => $res."
  • \${".$varname."}: ". + get_string('export_attempts_filename_pattern_variable_'.$varname, 'quiz_archiver'). + "
  • " , "" ), 'forbiddenchars' => implode('', ArchiveJob::FILENAME_FORBIDDEN_CHARACTERS), @@ -161,12 +185,15 @@ $set->set_locked_flag_options(admin_setting_flag::ENABLED, false); $settings->add($set); + // Attempt filename pattern. $set = new admin_setting_attempt_filename_pattern('quiz_archiver/job_preset_export_attempts_filename_pattern', get_string('export_attempts_filename_pattern', 'quiz_archiver'), get_string('export_attempts_filename_pattern_help', 'quiz_archiver', [ 'variables' => array_reduce( ArchiveJob::ATTEMPT_FILENAME_PATTERN_VARIABLES, - fn ($res, $varname) => $res . "
  • \${".$varname."}: ".get_string('export_attempts_filename_pattern_variable_'.$varname, 'quiz_archiver')."
  • " + fn ($res, $varname) => $res."
  • \${".$varname."}: ". + get_string('export_attempts_filename_pattern_variable_'.$varname, 'quiz_archiver'). + "
  • " , "" ), 'forbiddenchars' => implode('', ArchiveJob::FILENAME_FORBIDDEN_CHARACTERS), @@ -177,6 +204,58 @@ $set->set_locked_flag_options(admin_setting_flag::ENABLED, false); $settings->add($set); + // Image optimization. + $set = new admin_setting_configcheckbox('quiz_archiver/job_preset_export_attempts_image_optimize', + get_string('export_attempts_image_optimize', 'quiz_archiver'), + get_string('export_attempts_image_optimize_help', 'quiz_archiver'), + '0', + ); + $set->set_locked_flag_options(admin_setting_flag::ENABLED, false); + $settings->add($set); + + // Image optimization: Max width. + $set = new admin_setting_configtext('quiz_archiver/job_preset_export_attempts_image_optimize_width', + get_string('export_attempts_image_optimize_width', 'quiz_archiver'), + get_string('export_attempts_image_optimize_width_help', 'quiz_archiver'), + '1280', + PARAM_INT + ); + $set->set_locked_flag_options(admin_setting_flag::ENABLED, false); + $set->add_dependent_on('quiz_archiver/job_preset_export_attempts_image_optimize'); + $settings->add($set); + + // Image optimization: Max height. + $set = new admin_setting_configtext('quiz_archiver/job_preset_export_attempts_image_optimize_height', + get_string('export_attempts_image_optimize_height', 'quiz_archiver'), + get_string('export_attempts_image_optimize_height_help', 'quiz_archiver'), + '1280', + PARAM_INT + ); + $set->set_locked_flag_options(admin_setting_flag::ENABLED, false); + $set->add_dependent_on('quiz_archiver/job_preset_export_attempts_image_optimize'); + $settings->add($set); + + // Image optimization: Quality. + $set = new admin_setting_configtext('quiz_archiver/job_preset_export_attempts_image_optimize_quality', + get_string('export_attempts_image_optimize_quality', 'quiz_archiver'), + get_string('export_attempts_image_optimize_quality_help', 'quiz_archiver'), + '85', + PARAM_INT + ); + $set->set_locked_flag_options(admin_setting_flag::ENABLED, false); + $set->add_dependent_on('quiz_archiver/job_preset_export_attempts_image_optimize'); + $settings->add($set); + + // Keep HTML files. + $set = new admin_setting_configcheckbox('quiz_archiver/job_preset_export_attempts_keep_html_files', + get_string('export_attempts_keep_html_files', 'quiz_archiver'), + get_string('export_attempts_keep_html_files_help', 'quiz_archiver'), + '0', + ); + $set->set_locked_flag_options(admin_setting_flag::ENABLED, false); + $settings->add($set); + + // Archive autodelete. $set = new admin_setting_configcheckbox('quiz_archiver/job_preset_archive_autodelete', get_string('archive_autodelete', 'quiz_archiver'), get_string('archive_autodelete_help', 'quiz_archiver'), @@ -185,6 +264,7 @@ $set->set_locked_flag_options(admin_setting_flag::ENABLED, true); $settings->add($set); + // Archive autodelete: Retention time. $set = new admin_setting_configduration('quiz_archiver/job_preset_archive_retention_time', get_string('archive_retention_time', 'quiz_archiver'), get_string('archive_retention_time_help', 'quiz_archiver'), @@ -195,24 +275,27 @@ $set->add_dependent_on('quiz_archiver/job_preset_archive_autodelete'); $settings->add($set); - // Time-Stamp Protocol settings + // Time-Stamp Protocol settings. $settings->add(new admin_setting_heading('quit_archiver/header_tsp', get_string('setting_header_tsp', 'quiz_archiver'), get_string('setting_header_tsp_desc', 'quiz_archiver') )); + // Enable TSP. $settings->add(new admin_setting_configcheckbox('quiz_archiver/tsp_enable', get_string('setting_tsp_enable', 'quiz_archiver'), get_string('setting_tsp_enable_desc', 'quiz_archiver'), '0' )); + // TSP automatic signing. $settings->add(new admin_setting_configcheckbox('quiz_archiver/tsp_automatic_signing', get_string('setting_tsp_automatic_signing', 'quiz_archiver'), get_string('setting_tsp_automatic_signing_desc', 'quiz_archiver'), '1' )); + // TSP server URL. $settings->add(new admin_setting_configtext('quiz_archiver/tsp_server_url', get_string('setting_tsp_server_url', 'quiz_archiver'), get_string('setting_tsp_server_url_desc', 'quiz_archiver'), diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..0224b6b --- /dev/null +++ b/styles.css @@ -0,0 +1,40 @@ +/* This file is part of Moodle - http://moodle.org/ + * + * Moodle is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Moodle is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Moodle. If not, see . + */ + +/** + * Custom style definitions for the quiz_archiver plugin. + * + * @copyright 2024 Niels Gandraß + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +/* Archive creation form */ +.quiz_archiver-archive-quiz-form > form > fieldset:last-of-type { + border-bottom: 0; +} + +/* Job overview table */ +.quiz_archiver-job-overview-table > .pagination:nth-of-type(1) { + display: none; +} + +.quiz_archiver-job-overview-table th.header { + border-top: 0; +} + +.quiz_archiver-job-overview-table th > a { + display: inline-block; +} diff --git a/templates/job_details.mustache b/templates/job_details.mustache index 6922008..05170bc 100644 --- a/templates/job_details.mustache +++ b/templates/job_details.mustache @@ -123,9 +123,20 @@
    diff --git a/templates/overview.mustache b/templates/overview.mustache index c4d37b3..1f641c9 100644 --- a/templates/overview.mustache +++ b/templates/overview.mustache @@ -21,14 +21,8 @@ Example context (json): { + "archiveQuizForm": "", "baseurl": "https://example.com/mod/quiz/archiver/", - "jobInitiationForm": "[...]", - "jobInitiationStatusAlert": { - "color": "success", - "dismissible": true, - "message": "Job initiated successfully.", - "returnMessage": "Return to the overview page." - }, "jobOverviewTable": "", "jobs": [ { @@ -40,42 +34,34 @@ "json": "{...}" } ], - "quizMissingSomethingWarning": "
    [...]
    " + "time": 0 } }} -{{#quizMissingSomethingWarning}} - {{{quizMissingSomethingWarning}}} -

    -{{/quizMissingSomethingWarning}} -{{! Archive quiz form }} -
    - {{{jobInitiationForm}}} - {{#jobInitiationStatusAlert}} - - {{/jobInitiationStatusAlert}} +{{! Quiz archive form }} +
    + {{{archiveQuizForm}}}
    {{! List of existing archives }} {{#jobOverviewTable}}

    {{#str}} job_overview, quiz_archiver {{/str}} - +

    + {{#str}} last_updated, quiz_archiver {{/str}}: + {{#userdate}} {{time}}, %T {{/userdate}} +
    +
    {{{jobOverviewTable}}}
    diff --git a/tests/archivejob_test.php b/tests/archivejob_test.php new file mode 100644 index 0000000..8d545e1 --- /dev/null +++ b/tests/archivejob_test.php @@ -0,0 +1,1346 @@ +. + +/** + * Tests for the ArchiveJob class + * + * @package quiz_archiver + * @copyright 2024 Niels Gandraß + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace quiz_archiver; + +use context_module; +use context_system; + +/** + * Tests for the ArchiveJob class + */ +final class archivejob_test extends \advanced_testcase { + + /** + * Returns the data generator for the quiz_archiver plugin + * + * @return \quiz_archiver_generator The data generator for the quiz_archiver plugin + */ + // @codingStandardsIgnoreLine + public static function getDataGenerator(): \quiz_archiver_generator { + return parent::getDataGenerator()->get_plugin_generator('quiz_archiver'); + } + + /** + * Tests the creation of a new archive job + * + * @covers \quiz_archiver\ArchiveJob::create + * @covers \quiz_archiver\ArchiveJob::__construct + * @covers \quiz_archiver\ArchiveJob::get_by_jobid + * + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_create_archive_job(): void { + global $DB; + $this->resetAfterTest(); + + // Create new archive job. + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $job = ArchiveJob::create( + '10000000-1234-5678-abcd-ef4242424242', + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + null, + 'TEST-WS-TOKEN-1', + $mocks->attempts, + $mocks->settings + ); + + // Check that the job was created. + $this->assertNotNull($job, 'Job was not created'); + $this->assertEquals( + $job, + ArchiveJob::get_by_jobid('10000000-1234-5678-abcd-ef4242424242'), + 'Job was not found in database' + ); + + // Check that the job has the correct settings. + $this->assertEquals($mocks->settings, $job->get_settings(), 'Job settings were not stored correctly'); + + // Check if attempt ids were stored correctly. + $this->assertEqualsCanonicalizing( + array_values($mocks->attempts), + array_values($DB->get_records(ArchiveJob::ATTEMPTS_TABLE_NAME, ['jobid' => $job->get_id()], '', 'userid, attemptid')), + 'Job attempt ids were not stored correctly' + ); + } + + /** + * Tests the retrieval of an archive job by its internal database ID + * + * @dataProvider job_get_by_id_data_provider + * @covers \quiz_archiver\ArchiveJob::get_id + * @covers \quiz_archiver\ArchiveJob::get_by_id + * + * @param bool $shouldfail Whether the test should fail + * @return void + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_job_get_by_id(bool $shouldfail): void { + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $job = ArchiveJob::create( + '10000000-1234-5678-abcd-ef4242123456', + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + null, + 'TEST-WS-TOKEN-1', + $mocks->attempts, + $mocks->settings + ); + $jobid = $job->get_id(); + + if ($shouldfail) { + $this->expectException(\dml_exception::class); + $jobid += 100000; + } + $this->assertEquals($job, ArchiveJob::get_by_id($jobid)); + } + + /** + * Data provider for test_job_get_by_id + * + * @return array Test data + */ + public static function job_get_by_id_data_provider(): array { + return [ + 'Existing Job' => ['shouldfail' => false], + 'Non-Existing Job' => ['shouldfail' => true], + ]; + } + + /** + * Tests the duplicate UUID detection during job creation + * + * @covers \quiz_archiver\ArchiveJob::create + * @covers \quiz_archiver\ArchiveJob::get_by_jobid + * @covers \quiz_archiver\ArchiveJob::exists_in_db + * + * @return void + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_create_job_duplicate_detection(): void { + // Create mock job. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $jobid = '10000000-dupe-dupe-dupe-ef1234567890'; + $job = ArchiveJob::create( + $jobid, + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + null, + 'TEST-WS-TOKEN-1', + $mocks->attempts, + $mocks->settings + ); + + // Assert that job was created. + $this->assertNotNull(ArchiveJob::get_by_jobid($jobid), 'Job was not created'); + + // Try to create second job with same UUID. + $this->expectException(\moodle_exception::class); + $jobduplicate = ArchiveJob::create( + $jobid, + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + null, + 'TEST-WS-TOKEN-1', + $mocks->attempts, + $mocks->settings + ); + } + + /** + * Test the deletion of an archive job + * + * @covers \quiz_archiver\ArchiveJob::create + * @covers \quiz_archiver\ArchiveJob::get_by_jobid + * @covers \quiz_archiver\ArchiveJob::delete + * + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_delete_archive_job(): void { + global $DB; + + // Create new archive job. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $job = ArchiveJob::create( + '20000000-1234-5678-abcd-ef4242424242', + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + null, + 'TEST-WS-TOKEN-2', + $mocks->attempts, + $mocks->settings + ); + + // Delete the job but remember its ID. + $this->assertNotNull(ArchiveJob::get_by_jobid('20000000-1234-5678-abcd-ef4242424242')); + $jobid = $job->get_id(); + $job->delete(); + + // Confirm that the job was deleted. + $this->assertEmpty( + $DB->get_records(ArchiveJob::JOB_TABLE_NAME, ['jobid' => $jobid]), + 'Job was not deleted from database' + ); + + // Confirm that the attempt ids were deleted. + $this->assertEmpty( + $DB->get_records(ArchiveJob::ATTEMPTS_TABLE_NAME, ['jobid' => $jobid]), + 'Attempt ids were not deleted from database' + ); + + // Confirm that the settings were deleted. + $this->assertEmpty( + $DB->get_records(ArchiveJob::JOB_SETTINGS_TABLE_NAME, ['jobid' => $jobid]), + 'Settings were not deleted from database' + ); + } + + /** + * Tests the creation and retrieval of multiple jobs for different quizzes + * as well as their metadata arrays. + * + * @covers \quiz_archiver\ArchiveJob::create + * @covers \quiz_archiver\ArchiveJob::link_artifact + * @covers \quiz_archiver\ArchiveJob::get_jobs + * @covers \quiz_archiver\ArchiveJob::get_metadata_for_jobs + * @covers \quiz_archiver\ArchiveJob::get_jobid + * @covers \quiz_archiver\ArchiveJob::get_courseid + * @covers \quiz_archiver\ArchiveJob::get_cmid + * @covers \quiz_archiver\ArchiveJob::get_quizid + * @covers \quiz_archiver\ArchiveJob::get_userid + * @covers \quiz_archiver\ArchiveJob::get_retentiontime + * @covers \quiz_archiver\ArchiveJob::is_autodelete_enabled + * @covers \quiz_archiver\ArchiveJob::get_settings + * @covers \quiz_archiver\ArchiveJob::convert_archive_settings_for_display + * + * @return void + * @throws \coding_exception + * @throws \dml_exception + * @throws \file_exception + * @throws \moodle_exception + * @throws \stored_file_creation_exception + */ + public function test_multiple_jobs_retrieval_and_metadata(): void { + global $DB; + $this->resetAfterTest(); + + // Generate data. + $mocks = []; + $jobs = []; + $artifacts = []; + for ($quizidx = 0; $quizidx < 3; $quizidx++) { + $mocks[$quizidx] = $this->getDataGenerator()->create_mock_quiz(); + for ($jobidx = 0; $jobidx < 3; $jobidx++) { + // Create job. + $jobs[$quizidx][$jobidx] = ArchiveJob::create( + '30000000-1234-5678-abcd-'.$quizidx.'0000000000'.$jobidx, + $mocks[$quizidx]->course->id, + $mocks[$quizidx]->quiz->cmid, + $mocks[$quizidx]->quiz->id, + $mocks[$quizidx]->user->id, + 3600 + $jobidx * $quizidx * 100, + 'TEST-WS-TOKEN', + $mocks[$quizidx]->attempts, + $mocks[$quizidx]->settings + ); + + // Attach artifact. + $artifacts[$quizidx][$jobidx] = $this->getDataGenerator()->create_artifact_file( + $mocks[$quizidx]->course->id, + $mocks[$quizidx]->quiz->cmid, + $mocks[$quizidx]->quiz->id, + 'test'.$quizidx.'-'.$jobidx.'.tar.gz' + ); + $jobs[$quizidx][$jobidx]->link_artifact( + $artifacts[$quizidx][$jobidx]->get_id(), + hash('sha256', 'foo bar baz') + ); + + // Generate mock TSP data. + $DB->insert_record(TSPManager::TSP_TABLE_NAME, [ + 'jobid' => $jobs[$quizidx][$jobidx]->get_id(), + 'timecreated' => time(), + 'server' => 'localhost', + 'timestampquery' => 'tspquery', + 'timestampreply' => 'tspreply', + ]); + } + } + + // Find jobs in database. + foreach ($mocks as $quizidx => $mock) { + $this->assertEqualsCanonicalizing( + array_values($jobs[$quizidx]), + array_values(ArchiveJob::get_jobs($mock->course->id, $mock->quiz->cmid, $mock->quiz->id)), + 'Jobs for quiz '.$quizidx.' were not returned properly by get_jobs()' + ); + } + + // Test metadata retrieval. + foreach ($mocks as $quizidx => $mock) { + $metadata = ArchiveJob::get_metadata_for_jobs($mock->course->id, $mock->quiz->cmid, $mock->quiz->id); + + // Check that the metadata array contains the correct number of jobs. + $this->assertSameSize( + $jobs[$quizidx], + $metadata, + 'Metadata for quiz '.$quizidx.' does not contain the correct number of jobs' + ); + + // Check that the metadata array contains the correct data. + foreach ($jobs[$quizidx] as $jobidx => $expectedjob) { + // Find job in metadata array. + $actualjobs = array_filter($metadata, function ($metadata) use ($expectedjob) { + return $metadata['id'] == $expectedjob->get_id(); + }); + + // Assure that job was found. + $this->assertCount( + 1, + $actualjobs, + 'Metadata for job '.$jobidx.' of quiz '.$quizidx.' could not uniquely be identified' + ); + + // Probe that the metadata contains the correct data. + $actualjob = array_pop($actualjobs); + // @codingStandardsIgnoreStart + $this->assertEquals($expectedjob->get_jobid(), $actualjob['jobid'], 'Jobid was not returned correctly'); + $this->assertEquals($expectedjob->get_courseid(), $actualjob['course']['id'], 'Courseid was not returned correctly'); + $this->assertEquals($expectedjob->get_cmid(), $actualjob['quiz']['cmid'], 'Course module id was not returned correctly'); + $this->assertEquals($expectedjob->get_quizid(), $actualjob['quiz']['id'], 'Quiz id was not returned correctly'); + $this->assertEquals($expectedjob->get_userid(), $actualjob['user']['id'], 'User id was not returned correctly'); + $this->assertEquals($expectedjob->get_retentiontime(), $actualjob['retentiontime'], 'Retentiontime was not returned correctly'); + $this->assertSame($expectedjob->is_autodelete_enabled(), $actualjob['autodelete'], 'Autodelete was not detected as enabled'); + $this->assertArrayHasKey('autodelete_str', $actualjob, 'Autodelete string was not generated correctly'); + $this->assertSameSize($expectedjob->get_settings(), $actualjob['settings'], 'Settings were not returned correctly'); + + // Check that the artifact file metadata was returned correctly. + $this->assertArrayHasKey('artifactfile', $actualjob, 'Artifact file metadata was not returned'); + $this->assertEquals($artifacts[$quizidx][$jobidx]->get_filename(), $actualjob['artifactfile']['name'], 'Artifact filename was not returned correctly'); + $this->assertEquals($artifacts[$quizidx][$jobidx]->get_filesize(), $actualjob['artifactfile']['size'], 'Artifact size was not returned correctly'); + $this->assertNotEmpty($actualjob['artifactfile']['downloadurl'], 'Artifact download URL was not returned'); + $this->assertNotEmpty($actualjob['artifactfile']['size_human'], 'Artifact size in human readable format was not returned'); + $this->assertEquals(hash('sha256', 'foo bar baz'), $actualjob['artifactfile']['checksum'], 'Artifact checksum was not returned correctly'); + + // Check that the TSP data was returned correctly. + $this->assertArrayHasKey('tsp', $actualjob, 'TSP data was not returned'); + $this->assertEquals('localhost', $actualjob['tsp']['server'], 'TSP server was not returned correctly'); + $this->assertNotEmpty($actualjob['tsp']['timecreated'], 'TSP creation time was not returned'); + $this->assertNotEmpty($actualjob['tsp']['queryfiledownloadurl'], 'TSP queryfile download URL was not returned'); + $this->assertNotEmpty($actualjob['tsp']['replyfiledownloadurl'], 'TSP replyfile download URL was not returned'); + // @codingStandardsIgnoreEnd + } + } + } + + /** + * Test status changes of jobs + * + * @dataProvider set_job_status_data_provider + * @covers \quiz_archiver\ArchiveJob::set_status + * @covers \quiz_archiver\ArchiveJob::get_status + * @covers \quiz_archiver\ArchiveJob::is_complete + * + * @param string $status + * @param bool $iscompleted + * @return void + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_set_job_status(string $status, bool $iscompleted): void { + // Create test job. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $expectedjob = ArchiveJob::create( + '40000000-1234-5678-abcd-ef4242424242', + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + null, + 'TEST-WS-TOKEN', + $mocks->attempts, + $mocks->settings, + ArchiveJob::STATUS_UNINITIALIZED + ); + + // Initial job status. + $this->assertEquals( + ArchiveJob::STATUS_UNINITIALIZED, + ArchiveJob::get_by_jobid('40000000-1234-5678-abcd-ef4242424242')->get_status(), + 'Initial job status was not set correctly' + ); + + // Test status changes. + $expectedjob->set_status($status); + $actualjob = ArchiveJob::get_by_jobid('40000000-1234-5678-abcd-ef4242424242'); + $this->assertEquals($status, $actualjob->get_status(), 'Job status was not set correctly to '.$status); + $this->assertEquals($iscompleted, $actualjob->is_complete(), 'Job completion was not detected correctly'); + } + + /** + * Data provider for test_set_job_status + * + * @return array[] Test data + */ + public static function set_job_status_data_provider(): array { + return [ + 'STATUS_UNKNOWN' => ['status' => ArchiveJob::STATUS_UNKNOWN, 'iscompleted' => false], + 'STATUS_UNINITIALIZED' => ['status' => ArchiveJob::STATUS_UNINITIALIZED, 'iscompleted' => false], + 'STATUS_AWAITING_PROCESSING' => ['status' => ArchiveJob::STATUS_AWAITING_PROCESSING, 'iscompleted' => false], + 'STATUS_RUNNING' => ['status' => ArchiveJob::STATUS_RUNNING, 'iscompleted' => false], + 'STATUS_WAITING_FOR_BACKUP' => ['status' => ArchiveJob::STATUS_WAITING_FOR_BACKUP, 'iscompleted' => false], + 'STATUS_FINALIZING' => ['status' => ArchiveJob::STATUS_FINALIZING, 'iscompleted' => false], + 'STATUS_FINISHED' => ['status' => ArchiveJob::STATUS_FINISHED, 'iscompleted' => true], + 'STATUS_FAILED' => ['status' => ArchiveJob::STATUS_FAILED, 'iscompleted' => true], + 'STATUS_TIMEOUT' => ['status' => ArchiveJob::STATUS_TIMEOUT, 'iscompleted' => true], + 'STATUS_DELETED' => ['status' => ArchiveJob::STATUS_DELETED, 'iscompleted' => true], + ]; + } + + /** + * Test status changes of jobs with statusextras + * + * @dataProvider set_job_status_with_statusextras_data_provider + * @covers \quiz_archiver\ArchiveJob::set_status + * @covers \quiz_archiver\ArchiveJob::get_status + * @covers \quiz_archiver\ArchiveJob::get_statusextras + * + * @param string $status Job status to set + * @param array|null $statusextras Statusextras to set + * @return void + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_set_job_status_with_statusextras(string $status, ?array $statusextras): void { + // Create test job. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $expectedjob = ArchiveJob::create( + '40000123-1234-5678-abcd-ef4242424242', + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + null, + 'TEST-WS-TOKEN', + $mocks->attempts, + $mocks->settings, + ArchiveJob::STATUS_UNINITIALIZED + ); + + // Initial job status. + $this->assertEquals( + ArchiveJob::STATUS_UNINITIALIZED, + ArchiveJob::get_by_jobid('40000123-1234-5678-abcd-ef4242424242')->get_status(), + 'Initial job status was not set correctly' + ); + + // Test status changes. + $expectedjob->set_status($status, $statusextras); + $actualjob = ArchiveJob::get_by_jobid('40000123-1234-5678-abcd-ef4242424242'); + $this->assertEquals($status, $actualjob->get_status(), 'Job status was not set correctly to '.$status); + $this->assertEquals($statusextras, $actualjob->get_statusextras(), 'Job statusextras were not set correctly'); + } + + /** + * Data provider for test_set_job_status_with_statusextras + * + * @return array[] Test data + */ + public static function set_job_status_with_statusextras_data_provider(): array { + return [ + 'No statusextras' => [ + 'status' => ArchiveJob::STATUS_AWAITING_PROCESSING, + 'statusextras' => null, + ], + 'Simple progress' => [ + 'status' => ArchiveJob::STATUS_RUNNING, + 'statusextras' => ['progress' => 42], + ], + 'Complex data' => [ + 'status' => ArchiveJob::STATUS_RUNNING, + 'statusextras' => ['progress' => 100, 'foo' => 'bar'], + ], + 'Nested data' => [ + 'status' => ArchiveJob::STATUS_RUNNING, + 'statusextras' => ['progress' => 0, 'nested' => ['foo' => 'bar']], + ], + ]; + } + + /** + * Test webservice token access checks + * + * @covers \quiz_archiver\ArchiveJob::has_write_access + * @covers \quiz_archiver\ArchiveJob::has_read_access + * + * @return void + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_wstoken_access_checks(): void { + // Generate test data. + $wstokens = [ + md5('TEST-WS-TOKEN-1'), + md5('TEST-WS-TOKEN-2'), + md5('TEST-WS-TOKEN-3'), + md5('TEST-WS-TOKEN-4'), + md5('TEST-WS-TOKEN-5'), + ]; + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + + // Create jobs and test all tokens against each job. + foreach ($wstokens as $wstoken) { + $job = ArchiveJob::create( + 'xxx-'.$wstoken, + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + null, + $wstoken, + $mocks->attempts, + $mocks->settings + ); + + // Validate token access. + foreach ($wstokens as $otherwstoken) { + $this->assertSame( + $wstoken === $otherwstoken, + $job->has_write_access($otherwstoken), + 'Webservice token access was not validated correctly (write access)' + ); + $this->assertSame( + $wstoken === $otherwstoken, + $job->has_read_access($otherwstoken), + 'Webservice token access was not validated correctly (read access)' + ); + } + } + } + + /** + * Test the deletion of a webservice token + * + * @covers \quiz_archiver\ArchiveJob::delete_webservice_token + * + * @return void + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_delete_webservice_token(): void { + // Create temporary webservice token. + global $CFG, $DB; + if ($CFG->branch <= 401) { + // TODO (MDL-0): Remove after deprecation of Moodle 4.1 (LTS) on 08-12-2025. + require_once($CFG->dirroot.'/lib/externallib.php'); + $wstoken = \external_generate_token( + EXTERNAL_TOKEN_PERMANENT, + 1, + 1, + context_system::instance(), + time() + 3600, + 0 + ); + } else { + $wstoken = \core_external\util::generate_token( + EXTERNAL_TOKEN_PERMANENT, + \core_external\util::get_service_by_id(1), + 1, + context_system::instance(), + time() + 3600, + 0 + ); + } + + // Create job and test token access. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $job = ArchiveJob::create( + 'xxx-'.$wstoken, + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + null, + $wstoken, + $mocks->attempts, + $mocks->settings + ); + + $this->assertNotEmpty( + $DB->get_record('external_tokens', ['token' => $wstoken]), + 'Webservice token was not created correctly' + ); + $job->delete_webservice_token(); + $this->assertEmpty( + $DB->get_record('external_tokens', ['token' => $wstoken]), + 'Webservice token was not deleted correctly' + ); + } + + /** + * Test job timeout + * + * @covers \quiz_archiver\ArchiveJob::timeout_if_overdue + * + * @return void + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_job_timeout(): void { + // Prepare job. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $job = ArchiveJob::create( + '12300000-1234-5678-abcd-ef4242424242', + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + 1, + 'TEST-WS-TOKEN', + $mocks->attempts, + $mocks->settings, + ArchiveJob::STATUS_RUNNING, + ); + + // Not timed out job should not he set to timed out. + $this->assertFalse($job->timeout_if_overdue(60), 'Job seems to have been set to timed out before timeout'); + $this->assertSame(ArchiveJob::STATUS_RUNNING, $job->get_status(), 'Job status was changed to timed out before timeout'); + + // Time out job. + sleep(1); // Ensure that at least one second has passed. + $this->assertTrue($job->timeout_if_overdue(0), 'Job seems to have not been set to timed out after timeout'); + $this->assertSame(ArchiveJob::STATUS_TIMEOUT, $job->get_status(), 'Job status was not changed to timed out after timeout'); + + // Do not timeout a finished job. + $job->set_status(ArchiveJob::STATUS_FINISHED); + $this->assertFalse($job->timeout_if_overdue(0), 'Finished job seems to have been set to timed out'); + $this->assertSame(ArchiveJob::STATUS_FINISHED, $job->get_status(), 'Finished job was changed to timed out'); + } + + /** + * Tests the linking of an artifact file to a job + * + * @covers \quiz_archiver\ArchiveJob::link_artifact + * @covers \quiz_archiver\ArchiveJob::has_artifact + * @covers \quiz_archiver\ArchiveJob::get_artifact + * @covers \quiz_archiver\ArchiveJob::get_artifact_checksum + * + * @return void + * @throws \dml_exception + * @throws \file_exception + * @throws \moodle_exception + * @throws \stored_file_creation_exception + */ + public function test_artifact_linking(): void { + // Create test job. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $job = ArchiveJob::create( + '60000000-1234-5678-abcd-ef4242424242', + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + null, + 'TEST-WS-TOKEN', + $mocks->attempts, + $mocks->settings + ); + $this->assertNull($job->get_artifact(), 'Job artifact file was not null before linking'); + $this->assertFalse($job->has_artifact(), 'New job believes that it has an artifact file'); + + // Create and link artifact file. + $artifact = $this->getDataGenerator()->create_artifact_file( + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + 'test.tar.gz' + ); + $sha256dummy = hash('sha256', 'foo bar baz'); + $job->link_artifact($artifact->get_id(), $sha256dummy); + + // Check that the artifact file was linked correctly. + $this->assertTrue($job->has_artifact(), 'Job artifact file was not linked'); + $this->assertEquals($artifact, $job->get_artifact(), 'Linked artifact file differs from original'); + $this->assertSame($sha256dummy, $job->get_artifact_checksum(), 'Artifact checksum was not stored correctly'); + } + + /** + * Tests the deletion of an artifact file + * + * @covers \quiz_archiver\ArchiveJob::delete_artifact + * + * @return void + * @throws \dml_exception + * @throws \file_exception + * @throws \moodle_exception + * @throws \stored_file_creation_exception + */ + public function test_artifact_deletion(): void { + // Create test job and link dummy artifact file. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $artifact = $this->getDataGenerator()->create_artifact_file( + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + 'test.tar.gz' + ); + $job = ArchiveJob::create( + '70000000-1234-5678-abcd-ef4242424242', + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + null, + 'TEST-WS-TOKEN', + $mocks->attempts, + $mocks->settings + ); + $job->link_artifact($artifact->get_id(), hash('sha256', 'foo bar baz')); + + // Delete artifact and ensure that the underlying file was delete correctly. + $job->delete_artifact(); + // @codingStandardsIgnoreStart + $this->assertNull($job->get_artifact(), 'Job still returned an artifact file after deletion'); + $this->assertFalse($job->has_artifact(), 'Job believes it still has an artifact file'); + $this->assertFalse(get_file_storage()->get_file_by_id($artifact->get_id()), 'Artifact file was not deleted from file storage'); + $this->assertSame(ArchiveJob::STATUS_DELETED, $job->get_status(), 'Job status was not set to deleted'); + // @codingStandardsIgnoreEnd + } + + /** + * Tests the deletion of expired artifact files + * + * @covers \quiz_archiver\ArchiveJob::delete_expired_artifacts + * + * @return void + * @throws \dml_exception + * @throws \file_exception + * @throws \moodle_exception + * @throws \stored_file_creation_exception + */ + public function test_delete_expired_artifacts(): void { + // Create test job that instantly expires and link dummy artifact file. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $artifact = $this->getDataGenerator()->create_artifact_file( + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + 'test.tar.gz' + ); + $job = ArchiveJob::create( + '80000000-1234-5678-abcd-ef4242424242', + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + -1, + 'TEST-WS-TOKEN', + $mocks->attempts, + $mocks->settings + ); + $job->link_artifact($artifact->get_id(), hash('sha256', 'foo bar baz')); + + // Ensure that the artifact is present. + // @codingStandardsIgnoreStart + $this->assertTrue($job->has_artifact(), 'Job does not have an artifact file'); + $this->assertSame(1, ArchiveJob::delete_expired_artifacts(), 'Unexpected number of artifacts were reported as deleted'); + $this->assertFalse($job->has_artifact(), 'Job still has an artifact file after deletion'); + $this->assertFalse(get_file_storage()->get_file_by_id($artifact->get_id()), 'Artifact file was not deleted from file storage'); + // @codingStandardsIgnoreEnd + } + + /** + * Tests that the artifact checksum is null for non-existing artifacts + * + * @covers \quiz_archiver\ArchiveJob::get_artifact_checksum + * + * @return void + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_artifact_checksum_non_existing(): void { + // Generate data. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $job = ArchiveJob::create( + '99000000-1234-5678-abcd-ef4242424242', + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + null, + 'TEST-WS-TOKEN', + $mocks->attempts, + $mocks->settings + ); + + // Check that the artifact checksum is null for non-existing artifacts. + $this->assertNull($job->get_artifact_checksum(), 'Artifact checksum was not null for non-existing artifact'); + } + + /** + * Tests that temporary files can be linked to a job + * + * @covers \quiz_archiver\ArchiveJob::link_temporary_file + * @covers \quiz_archiver\ArchiveJob::get_temporary_files + * + * @return void + * @throws \dml_exception + * @throws \file_exception + * @throws \moodle_exception + * @throws \stored_file_creation_exception + */ + public function test_temporary_file_linking(): void { + // Generate data. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $tmpfiles = [ + $this->getDataGenerator()->create_artifact_file( + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + 'test1.tar.gz' + ), + $this->getDataGenerator()->create_artifact_file( + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + 'test2.tar.gz' + ), + $this->getDataGenerator()->create_artifact_file( + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + 'test3.tar.gz' + ), + ]; + + // Create job. + $job = ArchiveJob::create( + '90000000-1234-5678-abcd-ef4242424242', + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + null, + 'TEST-WS-TOKEN', + $mocks->attempts, + $mocks->settings + ); + + // Ensure no temporary files are linked. + $this->assertEmpty($job->get_temporary_files(), 'Job returned temporary files before linking'); + + // Link files and check that they were linked correctly. + foreach ($tmpfiles as $tmpfile) { + $job->link_temporary_file($tmpfile->get_pathnamehash()); + } + + $actualtempfiles = $job->get_temporary_files(); + foreach ($tmpfiles as $tmpfile) { + $this->assertEquals($tmpfile, $actualtempfiles[$tmpfile->get_id()], 'Temporary file was not linked correctly'); + } + } + + /** + * Tests that temporary files are deleted properly + * + * @covers \quiz_archiver\ArchiveJob::delete_temporary_files + * + * @return void + * @throws \dml_exception + * @throws \file_exception + * @throws \moodle_exception + * @throws \stored_file_creation_exception + */ + public function test_temporary_file_deletion(): void { + // Generate data. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $tmpfiles = [ + $this->getDataGenerator()->create_artifact_file( + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + 'test1.tar.gz' + ), + $this->getDataGenerator()->create_artifact_file( + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + 'test2.tar.gz' + ), + $this->getDataGenerator()->create_artifact_file( + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + 'test3.tar.gz' + ), + ]; + + // Create job and link files. + $job = ArchiveJob::create( + 'a0000000-1234-5678-abcd-ef4242424242', + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + null, + 'TEST-WS-TOKEN', + $mocks->attempts, + $mocks->settings + ); + foreach ($tmpfiles as $tmpfile) { + $job->link_temporary_file($tmpfile->get_pathnamehash()); + } + + // Ensure link state, delete and check. + $this->assertCount(3, $job->get_temporary_files(), 'Job did not link all temporary files'); + $job->delete_temporary_files(); + + $this->assertEmpty($job->get_temporary_files(), 'Job still has temporary files after deletion'); + foreach ($tmpfiles as $tmpfile) { + $this->assertFalse( + get_file_storage()->get_file_by_id($tmpfile->get_id()), + 'Temporary file was not deleted from file storage' + ); + } + } + + /** + * Test archive filename pattern validation + * + * @covers \quiz_archiver\ArchiveJob::is_valid_archive_filename_pattern + * @covers \quiz_archiver\ArchiveJob::is_valid_filename_pattern + * + * @dataProvider archive_filename_pattern_data_provider + * + * @param string $pattern Pattern to test + * @param bool $isvalid Expected result + * @return void + */ + public function test_archive_filename_pattern_validation(string $pattern, bool $isvalid): void { + $this->assertSame( + $isvalid, + ArchiveJob::is_valid_archive_filename_pattern($pattern), + 'Archive filename pattern validation failed for pattern "'.$pattern.'"' + ); + } + + /** + * Data provider for test_archive_filename_pattern_validation() + * + * @return array[] Array of test cases + */ + public static function archive_filename_pattern_data_provider(): array { + return [ + 'Default pattern' => [ + 'pattern' => 'quiz-archive-${courseshortname}-${courseid}-${quizname}-${quizid}_${date}-${time}', + 'isValid' => true, + ], + 'All allowed variables' => [ + 'pattern' => array_reduce( + ArchiveJob::ARCHIVE_FILENAME_PATTERN_VARIABLES, + function ($carry, $item) { + return $carry.'${'.$item.'}'; + }, + '' + ), + 'isValid' => true, + ], + 'Allowed variables with additional brackets' => [ + 'pattern' => 'quiz-{quizname}_${quizname}-{quizid}_${quizid}', + 'isValid' => true, + ], + 'Invalid variable' => [ + 'pattern' => 'Foo ${foo} Bar ${bar} Baz ${baz}', + 'isValid' => false, + ], + 'Forbidden characters' => [ + 'pattern' => 'quiz-archive: foo!bar', + 'isValid' => false, + ], + 'Only invalid characters' => [ + 'pattern' => '.!', + 'isValid' => false, + ], + 'Dot' => [ + 'pattern' => '.', + 'isValid' => false, + ], + 'Empty pattern' => [ + 'pattern' => '', + 'isValid' => false, + ], + ]; + } + + /** + * Test attempt filename pattern validation + * + * @covers \quiz_archiver\ArchiveJob::is_valid_attempt_filename_pattern + * @covers \quiz_archiver\ArchiveJob::is_valid_filename_pattern + * + * @dataProvider attempt_filename_pattern_data_provider + * + * @param string $pattern Pattern to test + * @param bool $isvalid Expected result + * @return void + */ + public function test_attempt_filename_pattern_validation(string $pattern, bool $isvalid): void { + $this->assertSame( + $isvalid, + ArchiveJob::is_valid_attempt_filename_pattern($pattern), + 'Attempt filename pattern validation failed for pattern "'.$pattern.'"' + ); + } + + /** + * Data provider for test_attempt_filename_pattern_validation() + * + * @return array[] Array of test cases + */ + public static function attempt_filename_pattern_data_provider(): array { + return [ + 'Default pattern' => [ + 'pattern' => 'attempt-${attemptid}-${username}_${date}-${time}', + 'isValid' => true, + ], + 'All allowed variables' => [ + 'pattern' => array_reduce( + ArchiveJob::ATTEMPT_FILENAME_PATTERN_VARIABLES, + function ($carry, $item) { + return $carry.'${'.$item.'}'; + }, + '' + ), + 'isValid' => true, + ], + 'Allowed variables with additional brackets' => [ + 'pattern' => 'attempt-{quizname}_${quizname}-{quizid}_${quizid}', + 'isValid' => true, + ], + 'Invalid variable' => [ + 'pattern' => 'Foo ${foo} Bar ${bar} Baz ${baz}', + 'isValid' => false, + ], + 'Forbidden characters' => [ + 'pattern' => 'attempt: foo!bar', + 'isValid' => false, + ], + 'Only invalid characters' => [ + 'pattern' => '.!', + 'isValid' => false, + ], + 'Dot' => [ + 'pattern' => '.', + 'isValid' => false, + ], + 'Empty pattern' => [ + 'pattern' => '', + 'isValid' => false, + ], + ]; + } + + /** + * Test generation of valid archive filenames + * + * @covers \quiz_archiver\ArchiveJob::generate_archive_filename + * @covers \quiz_archiver\ArchiveJob::sanitize_filename + * + * @return void + * @throws \coding_exception + * @throws \invalid_parameter_exception + */ + public function test_generate_archive_filename(): void { + // Generate data. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $cm = context_module::instance($mocks->quiz->cmid); + + // Full pattern. + $fullpattern = 'archive'; + foreach (ArchiveJob::ARCHIVE_FILENAME_PATTERN_VARIABLES as $var) { + $fullpattern .= '-${'.$var.'}'; + } + $filename = ArchiveJob::generate_archive_filename( + $mocks->course, + $cm, + $mocks->quiz, + $fullpattern + ); + $this->assertStringContainsString($mocks->course->id, $filename, 'Course ID was not found in filename'); + $this->assertStringContainsString($cm->id, $filename, 'Course module ID was not found in filename'); + $this->assertStringContainsString($mocks->quiz->id, $filename, 'Quiz ID was not found in filename'); + $this->assertStringContainsString($mocks->course->fullname, $filename, 'Course name was not found in filename'); + $this->assertStringContainsString($mocks->course->shortname, $filename, 'Course shortname was not found in filename'); + $this->assertStringContainsString($mocks->quiz->name, $filename, 'Quiz name was not found in filename'); + } + + /** + * Test generation of archive filenames without variables + * + * @covers \quiz_archiver\ArchiveJob::generate_archive_filename + * @covers \quiz_archiver\ArchiveJob::sanitize_filename + * + * @return void + * @throws \coding_exception + * @throws \invalid_parameter_exception + */ + public function test_generate_archive_filename_without_variables(): void { + // Generate data. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $cm = context_module::instance($mocks->quiz->cmid); + + // Full pattern. + $filename = ArchiveJob::generate_archive_filename( + $mocks->course, + $cm, + $mocks->quiz, + 'archive' + ); + $this->assertSame('archive', $filename, 'Filename was not generated correctly'); + } + + /** + * Test generation of archive filenames with invalid patterns + * + * @covers \quiz_archiver\ArchiveJob::generate_archive_filename + * @covers \quiz_archiver\ArchiveJob::sanitize_filename + * + * @return void + * @throws \coding_exception + * @throws \invalid_parameter_exception + */ + public function test_generate_archive_filename_invalid_pattern(): void { + // Generate data. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $cm = context_module::instance($mocks->quiz->cmid); + + // Test filename generation. + $this->expectException(\invalid_parameter_exception::class); + ArchiveJob::generate_archive_filename( + $mocks->course, + $cm, + $mocks->quiz, + '.' + ); + } + + /** + * Test generation of archive filenames with invalid variables + * + * @covers \quiz_archiver\ArchiveJob::generate_archive_filename + * @covers \quiz_archiver\ArchiveJob::sanitize_filename + * + * @return void + * @throws \coding_exception + * @throws \invalid_parameter_exception + */ + public function test_generate_archive_filename_invalid_variables(): void { + // Generate data. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $cm = context_module::instance($mocks->quiz->cmid); + + // Test filename generation. + $this->expectException(\invalid_parameter_exception::class); + $filename = ArchiveJob::generate_archive_filename( + $mocks->course, + $cm, + $mocks->quiz, + 'archive-${foo}${bar}${baz}${courseid}' + ); + } + + /** + * Test retrieval of human-readable job status + * + * @covers \quiz_archiver\ArchiveJob::get_status_display_args + * + * @dataProvider status_display_args_data_provider + * + * @param string $status + * @return void + * @throws \coding_exception + */ + public function test_status_display_args(string $status): void { + $res = ArchiveJob::get_status_display_args($status); + $this->assertSame( + get_string('job_status_'.$status, 'quiz_archiver'), + $res['text'], + 'Status display args were not returned correctly for status: '.$status + ); + $this->assertNotEmpty( + $res['color'], + 'Status display args did not contain a color for status: '.$status + ); + $this->assertNotEmpty( + $res['help'], + 'Status display args did not contain help text for status: '.$status + ); + } + + /** + * Data provider for test_status_display_args() + * + * @return array List of job status values to test + */ + public static function status_display_args_data_provider(): array { + return [ + ArchiveJob::STATUS_UNKNOWN => ['status' => ArchiveJob::STATUS_UNKNOWN], + ArchiveJob::STATUS_UNINITIALIZED => ['status' => ArchiveJob::STATUS_UNINITIALIZED], + ArchiveJob::STATUS_AWAITING_PROCESSING => ['status' => ArchiveJob::STATUS_AWAITING_PROCESSING], + ArchiveJob::STATUS_RUNNING => ['status' => ArchiveJob::STATUS_RUNNING], + ArchiveJob::STATUS_WAITING_FOR_BACKUP => ['status' => ArchiveJob::STATUS_WAITING_FOR_BACKUP], + ArchiveJob::STATUS_FINALIZING => ['status' => ArchiveJob::STATUS_FINALIZING], + ArchiveJob::STATUS_FINISHED => ['status' => ArchiveJob::STATUS_FINISHED], + ArchiveJob::STATUS_FAILED => ['status' => ArchiveJob::STATUS_FAILED], + ArchiveJob::STATUS_TIMEOUT => ['status' => ArchiveJob::STATUS_TIMEOUT], + ArchiveJob::STATUS_DELETED => ['status' => ArchiveJob::STATUS_DELETED], + ]; + } + + /** + * Test retrieval of human-readable job status with statusextras + * + * @dataProvider status_display_args_with_statusextras_data_provider + * @covers \quiz_archiver\ArchiveJob::get_status_display_args + * + * @param string $status + * @param array|null $statusextras + * @return void + * @throws \coding_exception + */ + public function test_status_display_args_with_statusextras(string $status, ?array $statusextras): void { + $res = ArchiveJob::get_status_display_args($status, $statusextras); + $this->assertSame( + get_string('job_status_'.$status, 'quiz_archiver'), + $res['text'], + 'Status display args were not returned correctly for status: '.$status + ); + $this->assertNotEmpty( + $res['color'], + 'Status display args did not contain a color for status: '.$status + ); + $this->assertNotEmpty( + $res['help'], + 'Status display args did not contain help text for status: '.$status + ); + $this->assertSame( + $statusextras ?? [], + $res['statusextras'], + 'Status display args did not contain expected statusextras' + ); + } + + /** + * Data provider for test_status_display_args_with_statusextras + * + * @return array[] Test data + */ + public static function status_display_args_with_statusextras_data_provider(): array { + return [ + 'No statusextras' => [ + 'status' => ArchiveJob::STATUS_AWAITING_PROCESSING, + 'statusextras' => null, + ], + 'Simple progress' => [ + 'status' => ArchiveJob::STATUS_RUNNING, + 'statusextras' => ['progress' => 42], + ], + 'Complex data' => [ + 'status' => ArchiveJob::STATUS_RUNNING, + 'statusextras' => ['progress' => 100, 'foo' => 'bar'], + ], + 'Nested data' => [ + 'status' => ArchiveJob::STATUS_RUNNING, + 'statusextras' => ['progress' => 0, 'nested' => ['foo' => 'bar']], + ], + ]; + } + + /** + * Tests the retrieval of a TSPManager instance via an ArchiveJob + * + * @covers \quiz_archiver\ArchiveJob::tspmanager + * + * @return void + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_tspmanager_get_instance(): void { + // Generate data. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $job = ArchiveJob::create( + 'asn00000-1234-5678-abcd-ef4242424242', + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + null, + 'TEST-WS-TOKEN', + $mocks->attempts, + $mocks->settings + ); + + // Test TSPManager creation. + $this->assertInstanceOf( + TSPManager::class, + $job->tspmanager(), + 'ArchiveJob::tspmanager() did not return an instance of TSPManager' + ); + } + +} diff --git a/tests/backupmanager_test.php b/tests/backupmanager_test.php new file mode 100644 index 0000000..0373624 --- /dev/null +++ b/tests/backupmanager_test.php @@ -0,0 +1,393 @@ +. + +/** + * Tests for the BackupManager class + * + * @package quiz_archiver + * @copyright 2024 Niels Gandraß + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace quiz_archiver; + +use backup; +use context_course; +use context_module; + +/** + * Tests for the BackupManager class + */ +final class backupmanager_test extends \advanced_testcase { + + /** + * Returns the data generator for the quiz_archiver plugin + * + * @return \quiz_archiver_generator The data generator for the quiz_archiver plugin + */ + // @codingStandardsIgnoreLine + public static function getDataGenerator(): \quiz_archiver_generator { + return parent::getDataGenerator()->get_plugin_generator('quiz_archiver'); + } + + /** + * Tests the backup of a course + * + * @covers \quiz_archiver\BackupManager::initiate_course_backup + * @covers \quiz_archiver\BackupManager::initiate_backup + * + * @return void + * @throws \base_setting_exception + * @throws \base_task_exception + * @throws \dml_exception + */ + public function test_course_backup(): void { + // Initiate a mock course backup. + $this->setAdminUser(); + $this->resetAfterTest(); + $mock = $this->getDataGenerator()->create_mock_quiz(); + $mock->user = get_admin(); + $backup = BackupManager::initiate_course_backup($mock->course->id, $mock->user->id); + + // Check if the backup was created correctly. + $this->assertNotEmpty($backup, 'Backup was not created'); + $this->assertNotEmpty($backup->backupid, 'Backup ID was not set'); + $this->assertEquals($mock->user->id, $backup->userid, 'User ID was not set correctly'); + $this->assertSame(context_course::instance($mock->course->id)->id, $backup->context, 'Context ID was not set correctly'); + $this->assertSame('backup', $backup->component, 'Component was not set correctly'); + $this->assertSame(backup::TYPE_1COURSE, $backup->filearea, 'File area was not set correctly'); + $this->assertNotEmpty($backup->filepath, 'File path was not set'); + $this->assertStringEndsWith('.mbz', $backup->filename, 'File name was not set'); + $this->assertNotEmpty($backup->pathnamehash, 'Path name hash was not set'); + $this->assertStringStartsWith('http', $backup->file_download_url, 'Download URL was not set'); + } + + /** + * Tests the backup of a quiz + * + * @covers \quiz_archiver\BackupManager::initiate_quiz_backup + * @covers \quiz_archiver\BackupManager::initiate_backup + * + * @return void + * @throws \base_setting_exception + * @throws \base_task_exception + * @throws \dml_exception + */ + public function test_quiz_backup(): void { + // Initiate a mock course backup. + $this->setAdminUser(); + $this->resetAfterTest(); + $mock = $this->getDataGenerator()->create_mock_quiz(); + $mock->user = get_admin(); + $backup = BackupManager::initiate_quiz_backup($mock->quiz->cmid, $mock->user->id); + + // Check if the backup was created correctly. + $this->assertNotEmpty($backup, 'Backup was not created'); + $this->assertNotEmpty($backup->backupid, 'Backup ID was not set'); + $this->assertEquals($mock->user->id, $backup->userid, 'User ID was not set correctly'); + $this->assertSame(context_module::instance($mock->quiz->cmid)->id, $backup->context, 'Context ID was not set correctly'); + $this->assertSame('backup', $backup->component, 'Component was not set correctly'); + $this->assertSame(backup::TYPE_1ACTIVITY, $backup->filearea, 'File area was not set correctly'); + $this->assertNotEmpty($backup->filepath, 'File path was not set'); + $this->assertStringEndsWith('.mbz', $backup->filename, 'File name was not set'); + $this->assertNotEmpty($backup->pathnamehash, 'Path name hash was not set'); + $this->assertStringStartsWith('http', $backup->file_download_url, 'Download URL was not set'); + } + + /** + * Tests the backup of a non-existing course + * + * @covers \quiz_archiver\BackupManager::initiate_course_backup + * @covers \quiz_archiver\BackupManager::initiate_backup + * + * @return void + * @throws \base_setting_exception + * @throws \base_task_exception + * @throws \dml_exception + */ + public function test_backup_missing_course(): void { + $this->resetAfterTest(); + $this->setAdminUser(); + + $this->expectException(\dml_exception::class); + BackupManager::initiate_course_backup(-1, get_admin()->id); + } + + /** + * Tests the backup of a non-existing quiz + * + * @covers \quiz_archiver\BackupManager::initiate_quiz_backup + * @covers \quiz_archiver\BackupManager::initiate_backup + * + * @return void + * @throws \base_setting_exception + * @throws \base_task_exception + * @throws \dml_exception + */ + public function test_backup_missing_quiz(): void { + $this->resetAfterTest(); + $this->setAdminUser(); + + $this->expectException(\dml_exception::class); + BackupManager::initiate_quiz_backup(-1, get_admin()->id); + } + + /** + * Tests backing up a course without the required privileges + * + * @covers \quiz_archiver\BackupManager::initiate_course_backup + * @covers \quiz_archiver\BackupManager::initiate_backup + * + * @return void + * @throws \base_setting_exception + * @throws \base_task_exception + * @throws \dml_exception + */ + public function test_backup_course_without_privileges(): void { + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + + $this->expectException(\backup_controller_exception::class); + $this->expectExceptionMessageMatches('/backup_user_missing_capability/'); + BackupManager::initiate_course_backup($mocks->course->id, $mocks->user->id); + } + + /** + * Tests backing up a quiz without the required privileges + * + * @covers \quiz_archiver\BackupManager::initiate_quiz_backup + * @covers \quiz_archiver\BackupManager::initiate_backup + * + * @return void + * @throws \base_setting_exception + * @throws \base_task_exception + * @throws \dml_exception + */ + public function test_backup_quiz_without_privileges(): void { + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + + $this->expectException(\backup_controller_exception::class); + $this->expectExceptionMessageMatches('/backup_user_missing_capability/'); + BackupManager::initiate_quiz_backup($mocks->quiz->cmid, $mocks->user->id); + } + + /** + * Tests the download URL generation with an explicitly given internal_wwwroot + * + * @covers \quiz_archiver\BackupManager::initiate_course_backup + * @covers \quiz_archiver\BackupManager::initiate_backup + * + * @return void + * @throws \base_setting_exception + * @throws \base_task_exception + * @throws \dml_exception + */ + public function test_backup_download_url_generation_with_internal_wwwroot(): void { + $this->setAdminUser(); + $this->resetAfterTest(); + $mock = $this->getDataGenerator()->create_mock_quiz(); + $mock->user = get_admin(); + set_config('internal_wwwroot', 'http://my-internal-hostname', 'quiz_archiver'); + + $backup = BackupManager::initiate_course_backup($mock->course->id, $mock->user->id); + $this->assertStringContainsString( + 'http://my-internal-hostname', + $backup->file_download_url, + 'Download URL was not generated correctly when using internal_wwwroot config' + ); + } + + /** + * Tests BackupManager instantiation by backupid + * + * @covers \quiz_archiver\BackupManager::__construct + * @covers \quiz_archiver\BackupManager::get_backupid + * @covers \quiz_archiver\BackupManager::get_userid + * @covers \quiz_archiver\BackupManager::get_type + * + * @return void + * @throws \base_setting_exception + * @throws \base_task_exception + * @throws \dml_exception + */ + public function test_initialization_by_existing_backupid(): void { + // Prepare a course and a quiz backup. + $this->setAdminUser(); + $this->resetAfterTest(); + $mock = $this->getDataGenerator()->create_mock_quiz(); + $mock->user = get_admin(); + $expectedcoursebackup = BackupManager::initiate_course_backup($mock->course->id, $mock->user->id); + $expectedquizbackup = BackupManager::initiate_quiz_backup($mock->quiz->cmid, $mock->user->id); + + // Course backup. + // @codingStandardsIgnoreStart + $actualcoursebackup = new BackupManager($expectedcoursebackup->backupid); + $this->assertNotEmpty($actualcoursebackup, 'Course backup was not created correctly from backup ID'); + $this->assertEquals($expectedcoursebackup->backupid, $actualcoursebackup->get_backupid(), 'Course backup ID was not set correctly'); + $this->assertEquals($expectedcoursebackup->userid, $actualcoursebackup->get_userid(), 'Course user ID was not set correctly'); + $this->assertSame(backup::TYPE_1COURSE, $actualcoursebackup->get_type(), 'Course backup type was not set correctly'); + + // Quiz backup. + $actualquizbackup = new BackupManager($expectedquizbackup->backupid); + $this->assertNotEmpty($actualquizbackup, 'Quiz backup was not created correctly from backup ID'); + $this->assertEquals($expectedquizbackup->backupid, $actualquizbackup->get_backupid(), 'Quiz backup ID was not set correctly'); + $this->assertEquals($expectedquizbackup->userid, $actualquizbackup->get_userid(), 'Quiz user ID was not set correctly'); + $this->assertSame(backup::TYPE_1ACTIVITY, $actualquizbackup->get_type(), 'Quiz backup type was not set correctly'); + // @codingStandardsIgnoreEnd + } + + /** + * Tests BackupManager instantiation by non-existing backupid + * + * @covers \quiz_archiver\BackupManager::__construct + * + * @return void + * @throws \dml_exception + */ + public function test_initialization_by_non_existing_backupid(): void { + $this->expectException(\dml_exception::class); + new BackupManager(-1); + } + + /** + * Tests access to backup status values + * + * @covers \quiz_archiver\BackupManager::get_status + * @covers \quiz_archiver\BackupManager::is_finished_successfully + * @covers \quiz_archiver\BackupManager::is_failed + * @covers \quiz_archiver\BackupManager::get_type + * + * @return void + * @throws \base_setting_exception + * @throws \base_task_exception + * @throws \dml_exception + */ + public function test_backup_status(): void { + // Prepare a course and a quiz backup. + $this->setAdminUser(); + $this->resetAfterTest(); + $mock = $this->getDataGenerator()->create_mock_quiz(); + $mock->user = get_admin(); + $expectedcoursebackup = BackupManager::initiate_course_backup($mock->course->id, $mock->user->id); + $expectedquizbackup = BackupManager::initiate_quiz_backup($mock->quiz->cmid, $mock->user->id); + + // Course backup. + $actualcoursebackup = new BackupManager($expectedcoursebackup->backupid); + $this->assertSame(backup::TYPE_1COURSE, $actualcoursebackup->get_type(), 'Course backup type was not retrieved correctly'); + $actualcoursebackup->is_finished_successfully(); + $actualcoursebackup->is_failed(); + + // Quiz backup. + $actualquizbackup = new BackupManager($expectedquizbackup->backupid); + $this->assertSame(backup::TYPE_1ACTIVITY, $actualquizbackup->get_type(), 'Quiz backup type was not retrieved correctly'); + $actualquizbackup->is_finished_successfully(); + $actualquizbackup->is_failed(); + } + + /** + * Tests backup to job association detection + * + * @covers \quiz_archiver\BackupManager::is_associated_with_job + * + * @return void + * @throws \base_setting_exception + * @throws \base_task_exception + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_backup_job_association(): void { + // Prepare a course and a quiz backup. + $this->setAdminUser(); + $this->resetAfterTest(); + $mock = $this->getDataGenerator()->create_mock_quiz(); + $mock->user = get_admin(); + $job = ArchiveJob::create( + '90000000-1234-5678-abcd-ef4242424242', + $mock->course->id, + $mock->quiz->cmid, + $mock->quiz->id, + $mock->user->id, + null, + 'TEST-WS-TOKEN-1', + $mock->attempts, + $mock->settings + ); + $expectedcoursebackup = BackupManager::initiate_course_backup($mock->course->id, $mock->user->id); + $expectedquizbackup = BackupManager::initiate_quiz_backup($mock->quiz->cmid, $mock->user->id); + + // Course backup. + $actualcoursebackup = new BackupManager($expectedcoursebackup->backupid); + $this->assertTrue( + $actualcoursebackup->is_associated_with_job($job), + 'Course backup was not detected as associated with the given job' + ); + + // Quiz backup. + $actualquizbackup = new BackupManager($expectedquizbackup->backupid); + $this->assertTrue( + $actualquizbackup->is_associated_with_job($job), + 'Quiz backup was not detected as associated with the given job' + ); + } + + /** + * Tests backup to job association detection with an invalid job + * + * @covers \quiz_archiver\BackupManager::is_associated_with_job + * + * @return void + * @throws \base_setting_exception + * @throws \base_task_exception + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_backup_invalid_job_association(): void { + // Prepare a course and a quiz backup. + $this->setAdminUser(); + $this->resetAfterTest(); + $mock = $this->getDataGenerator()->create_mock_quiz(); + $mock->user = get_admin(); + $job = ArchiveJob::create( + '10000000-1234-5678-abcd-ef4242424242', + $mock->course->id + 1, + $mock->quiz->cmid + 1, + $mock->quiz->id + 1, + $mock->user->id, + null, + 'TEST-WS-TOKEN-2', + $mock->attempts, + $mock->settings + ); + $expectedcoursebackup = BackupManager::initiate_course_backup($mock->course->id, $mock->user->id); + $expectedquizbackup = BackupManager::initiate_quiz_backup($mock->quiz->cmid, $mock->user->id); + + // Course backup. + $actualcoursebackup = new BackupManager($expectedcoursebackup->backupid); + $this->assertFalse( + $actualcoursebackup->is_associated_with_job($job), + 'Course backup was detected as associated with an unrelated job' + ); + + // Quiz backup. + $actualquizbackup = new BackupManager($expectedquizbackup->backupid); + $this->assertFalse( + $actualquizbackup->is_associated_with_job($job), + 'Quiz backup was detected as associated with an unrelated job' + ); + } + +} diff --git a/tests/classes/ArchiveJob_test.php b/tests/classes/ArchiveJob_test.php deleted file mode 100644 index 74ee6ec..0000000 --- a/tests/classes/ArchiveJob_test.php +++ /dev/null @@ -1,756 +0,0 @@ -. - -/** - * Tests for the ArchiveJob class - * - * @package quiz_archiver - * @copyright 2024 Niels Gandraß - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - -namespace quiz_archiver; - -use context_course; -use context_system; - -/** - * Tests for the ArchiveJob class - */ -class ArchiveJob_test extends \advanced_testcase { - - /** - * Generates a mock quiz to use in the tests - * - * @return \stdClass Created mock objects - */ - protected function generateMockQuiz(): \stdClass { - // Create course, course module and quiz - $this->resetAfterTest(); - - // Prepare user and course - $user = $this->getDataGenerator()->create_user(); - $course = $this->getDataGenerator()->create_course(); - $quiz = $this->getDataGenerator()->create_module('quiz', [ - 'course' => $course->id, - 'grade' => 100.0, - 'sumgrades' => 100 - ]); - - return (object) [ - 'user' => $user, - 'course' => $course, - 'quiz' => $quiz, - 'attempts' => [ - (object) ['userid' => 1, 'attemptid' => 1], - (object) ['userid' => 2, 'attemptid' => 42], - (object) ['userid' => 3, 'attemptid' => 1337], - ], - 'settings' => [ - 'num_attempts' => 3, - 'export_attempts' => 1, - 'export_report_section_header' => 1, - 'export_report_section_quiz_feedback' => 1, - 'export_report_section_question' => 1, - 'export_report_section_question_feedback' => 0, - 'export_report_section_general_feedback' => 1, - 'export_report_section_rightanswer' => 0, - 'export_report_section_history' => 1, - 'export_report_section_attachments' => 1, - 'export_quiz_backup' => 1, - 'export_course_backup' => 0, - 'archive_autodelete' => 1, - 'archive_retention_time' => '42w', - ], - ]; - } - - /** - * Generates a dummy artifact file, stored in the context of the given course. - * - * @param int $courseid ID of the course to store the file in - * @param int $cmid ID of the course module to store the file in - * @param int $quizid ID of the quiz to store the file in - * @param string $filename Name of the file to create - * @return \stored_file The created file handle - * @throws \file_exception - * @throws \stored_file_creation_exception - */ - protected function generateArtifactFile(int $courseid, int $cmid, int $quizid, string $filename): \stored_file { - $this->resetAfterTest(); - $ctx = context_course::instance($courseid); - - return get_file_storage()->create_file_from_string( - [ - 'contextid' => $ctx->id, - 'component' => FileManager::COMPONENT_NAME, - 'filearea' => FileManager::ARTIFACTS_FILEAREA_NAME, - 'itemid' => 0, - 'filepath' => "/{$courseid}/{$cmid}/{$quizid}/", - 'filename' => $filename, - 'timecreated' => time(), - 'timemodified' => time(), - ], - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.' - ); - } - - /** - * Tests the creation of a new archive job - * - * @throws \dml_exception - * @throws \moodle_exception - */ - public function test_create_archive_job(): void { - global $DB; - - // Create new archive job - $mocks = $this->generateMockQuiz(); - $job = ArchiveJob::create( - '10000000-1234-5678-abcd-ef4242424242', - $mocks->course->id, - $mocks->quiz->cmid, - $mocks->quiz->id, - $mocks->user->id, - null, - 'TEST-WS-TOKEN-1', - $mocks->attempts, - $mocks->settings - ); - - // Check that the job was created - $this->assertNotNull($job, 'Job was not created'); - $this->assertEquals( - $job, - ArchiveJob::get_by_jobid('10000000-1234-5678-abcd-ef4242424242'), - 'Job was not found in database' - ); - - // Check that the job has the correct settings - $this->assertEquals($mocks->settings, $job->get_settings(), 'Job settings were not stored correctly'); - - // Check if attempt ids were stored correctly - $this->assertEqualsCanonicalizing( - array_values($mocks->attempts), - array_values($DB->get_records(ArchiveJob::ATTEMPTS_TABLE_NAME, ['jobid' => $job->get_id()], '', 'userid, attemptid')), - 'Job attempt ids were not stored correctly' - ); - } - - /** - * Test the deletion of an archive job - * - * @throws \dml_exception - * @throws \moodle_exception - */ - public function test_delete_archive_job(): void { - global $DB; - - // Create new archive job - $mocks = $this->generateMockQuiz(); - $job = ArchiveJob::create( - '20000000-1234-5678-abcd-ef4242424242', - $mocks->course->id, - $mocks->quiz->cmid, - $mocks->quiz->id, - $mocks->user->id, - null, - 'TEST-WS-TOKEN-2', - $mocks->attempts, - $mocks->settings - ); - - // Delete the job but remember its ID - $this->assertNotNull(ArchiveJob::get_by_jobid('20000000-1234-5678-abcd-ef4242424242')); - $jobid = $job->get_id(); - $job->delete(); - - // Confirm that the job was deleted - $this->assertEmpty( - $DB->get_records(ArchiveJob::JOB_TABLE_NAME, ['jobid' => $jobid]), - 'Job was not deleted from database' - ); - - // Confirm that the attempt ids were deleted - $this->assertEmpty( - $DB->get_records(ArchiveJob::ATTEMPTS_TABLE_NAME, ['jobid' => $jobid]), - 'Attempt ids were not deleted from database' - ); - - // Confirm that the settings were deleted - $this->assertEmpty( - $DB->get_records(ArchiveJob::JOB_SETTINGS_TABLE_NAME, ['jobid' => $jobid]), - 'Settings were not deleted from database' - ); - } - - /** - * Tests the creation and retrieval of multiple jobs for different quizzes - * as well as their metadata arrays. - * - * @return void - */ - public function test_multiple_jobs_retrieval_and_metadata(): void { - // Generate data - $mocks = []; - $jobs = []; - for ($quizIdx= 0; $quizIdx < 3; $quizIdx++) { - $mocks[$quizIdx] = $this->generateMockQuiz(); - for ($jobIdx = 0; $jobIdx < 3; $jobIdx++) { - $jobs[$quizIdx][$jobIdx] = ArchiveJob::create( - '30000000-1234-5678-abcd-'.$quizIdx.'0000000000'.$jobIdx, - $mocks[$quizIdx]->course->id, - $mocks[$quizIdx]->quiz->cmid, - $mocks[$quizIdx]->quiz->id, - $mocks[$quizIdx]->user->id, - 3600 + $jobIdx * $quizIdx * 100, - 'TEST-WS-TOKEN', - $mocks[$quizIdx]->attempts, - $mocks[$quizIdx]->settings - ); - } - } - - // Find jobs in database - foreach ($mocks as $quizIdx => $mock) { - $this->assertEqualsCanonicalizing( - array_values($jobs[$quizIdx]), - array_values(ArchiveJob::get_jobs($mock->course->id, $mock->quiz->cmid, $mock->quiz->id)), - 'Jobs for quiz '.$quizIdx.' were not returned properly by get_jobs()' - ); - } - - // Test metadata retrieval - foreach ($mocks as $quizIdx => $mock) { - $metadata = ArchiveJob::get_metadata_for_jobs($mock->course->id, $mock->quiz->cmid, $mock->quiz->id); - - // Check that the metadata array contains the correct number of jobs - $this->assertSameSize( - $jobs[$quizIdx], - $metadata, - 'Metadata for quiz '.$quizIdx.' does not contain the correct number of jobs' - ); - - // Check that the metadata array contains the correct data - foreach ($jobs[$quizIdx] as $jobIdx => $expectedJob) { - // Find job in metadata array - $actualJobs = array_filter($metadata, function ($metadata) use ($expectedJob) { - return $metadata['id'] == $expectedJob->get_id(); - }); - - // Assure that job was found - $this->assertCount( - 1, - $actualJobs, - 'Metadata for job '.$jobIdx.' of quiz '.$quizIdx.' could not uniquely be identified' - ); - - // Probe that the metadata contains the correct data - $actualJob = array_pop($actualJobs); - $this->assertEquals($expectedJob->get_jobid(), $actualJob['jobid'], 'Jobid was not returned correctly'); - $this->assertEquals($expectedJob->get_course_id(), $actualJob['course']['id'], 'Courseid was not returned correctly'); - $this->assertEquals($expectedJob->get_cm_id(), $actualJob['quiz']['cmid'], 'Course module id was not returned correctly'); - $this->assertEquals($expectedJob->get_quiz_id(), $actualJob['quiz']['id'], 'Quiz id was not returned correctly'); - $this->assertEquals($expectedJob->get_user_id(), $actualJob['user']['id'], 'User id was not returned correctly'); - $this->assertEquals($expectedJob->get_retentiontime(), $actualJob['retentiontime'], 'Retentiontime was not returned correctly'); - $this->assertSame($expectedJob->is_autodelete_enabled(), $actualJob['autodelete'], 'Autodelete was not detected as enabled'); - $this->assertArrayHasKey('autodelete_str', $actualJob, 'Autodelete string was not generated correctly'); - $this->assertSameSize($expectedJob->get_settings(), $actualJob['settings'], 'Settings were not returned correctly'); - } - } - } - - /** - * Test status changes of jobs - * - * @return void - * @throws \dml_exception - * @throws \moodle_exception - */ - public function test_set_job_status(): void { - // Job statuses to test and whether they should be considered completed - $statuses_and_completion = [ - ArchiveJob::STATUS_UNKNOWN => false, - ArchiveJob::STATUS_UNINITIALIZED => false, - ArchiveJob::STATUS_AWAITING_PROCESSING => false, - ArchiveJob::STATUS_RUNNING => false, - ArchiveJob::STATUS_FINISHED => true, - ArchiveJob::STATUS_FAILED => true, - ArchiveJob::STATUS_TIMEOUT => true, - ArchiveJob::STATUS_DELETED => true, - ]; - - // Create test job - $mocks = $this->generateMockQuiz(); - $expectedJob = ArchiveJob::create( - '40000000-1234-5678-abcd-ef4242424242', - $mocks->course->id, - $mocks->quiz->cmid, - $mocks->quiz->id, - $mocks->user->id, - null, - 'TEST-WS-TOKEN', - $mocks->attempts, - $mocks->settings, - ArchiveJob::STATUS_UNINITIALIZED - ); - - // Initial job status - $this->assertEquals( - ArchiveJob::STATUS_UNINITIALIZED, - ArchiveJob::get_by_jobid('40000000-1234-5678-abcd-ef4242424242')->get_status(), - 'Initial job status was not set correctly' - ); - - // Test status changes - foreach ($statuses_and_completion as $status => $completion) { - $expectedJob->set_status($status); - $actualJob = ArchiveJob::get_by_jobid('40000000-1234-5678-abcd-ef4242424242'); - $this->assertEquals($status, $actualJob->get_status(),'Job status was not set correctly to '.$status); - $this->assertEquals($completion, $actualJob->is_complete(), 'Job completion was not detected correctly'); - } - } - - /** - * Test webservice token access checks - * - * @return void - * @throws \dml_exception - * @throws \moodle_exception - */ - public function test_wstoken_access_checks(): void { - // Generate test data - $wstokens = [ - md5('TEST-WS-TOKEN-1'), - md5('TEST-WS-TOKEN-2'), - md5('TEST-WS-TOKEN-3'), - md5('TEST-WS-TOKEN-4'), - md5('TEST-WS-TOKEN-5'), - ]; - $mocks = $this->generateMockQuiz(); - - // Create jobs and test all tokens against each job - foreach ($wstokens as $wstoken) { - $job = ArchiveJob::create( - 'xxx-'.$wstoken, - $mocks->course->id, - $mocks->quiz->cmid, - $mocks->quiz->id, - $mocks->user->id, - null, - $wstoken, - $mocks->attempts, - $mocks->settings - ); - - // Validate token access - foreach ($wstokens as $otherwstoken) { - $this->assertSame( - $wstoken === $otherwstoken, - $job->has_write_access($otherwstoken), - 'Webservice token access was not validated correctly' - ); - } - } - } - - /** - * Test the deletion of a webservice token - * - * @return void - * @throws \dml_exception - * @throws \moodle_exception - */ - public function test_delete_webservice_token(): void { - // Create temporary webservice token - global $CFG, $DB; - if ($CFG->branch <= 401) { - // TODO: Remove after deprecation of Moodle 4.1 (LTS) on 08-12-2025 - require_once($CFG->dirroot.'/lib/externallib.php'); - $wstoken = \external_generate_token(EXTERNAL_TOKEN_PERMANENT, 1, 1, context_system::instance(), time() + 3600, 0); - } else { - $wstoken = \core_external\util::generate_token(EXTERNAL_TOKEN_PERMANENT, \core_external\util::get_service_by_id(1), 1, context_system::instance(), time() + 3600, 0); - } - - // Create job and test token access - $mocks = $this->generateMockQuiz(); - $job = ArchiveJob::create( - 'xxx-'.$wstoken, - $mocks->course->id, - $mocks->quiz->cmid, - $mocks->quiz->id, - $mocks->user->id, - null, - $wstoken, - $mocks->attempts, - $mocks->settings - ); - - $this->assertNotEmpty($DB->get_record('external_tokens', ['token' => $wstoken]), 'Webservice token was not created correctly'); - $job->delete_webservice_token(); - $this->assertEmpty($DB->get_record('external_tokens', ['token' => $wstoken]), 'Webservice token was not deleted correctly'); - } - - /** - * Tests the linking of an artifact file to a job - * - * @return void - * @throws \dml_exception - * @throws \file_exception - * @throws \moodle_exception - * @throws \stored_file_creation_exception - */ - public function test_artifact_linking(): void { - // Create test job - $mocks = $this->generateMockQuiz(); - $job = ArchiveJob::create( - '60000000-1234-5678-abcd-ef4242424242', - $mocks->course->id, - $mocks->quiz->cmid, - $mocks->quiz->id, - $mocks->user->id, - null, - 'TEST-WS-TOKEN', - $mocks->attempts, - $mocks->settings - ); - $this->assertNull($job->get_artifact(), 'Job artifact file was not null before linking'); - $this->assertFalse($job->has_artifact(), 'New job believes that it has an artifact file'); - - // Create and link artifact file - $artifact = $this->generateArtifactFile($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id, 'test.tar.gz'); - $sha256dummy = hash('sha256', 'foo bar baz'); - $job->link_artifact($artifact->get_id(), $sha256dummy); - - // Check that the artifact file was linked correctly - $this->assertTrue($job->has_artifact(), 'Job artifact file was not linked'); - $this->assertEquals($artifact, $job->get_artifact(), 'Linked artifact file differs from original'); - $this->assertSame($sha256dummy, $job->get_artifact_checksum(), 'Artifact checksum was not stored correctly'); - } - - /** - * Tests the deletion of an artifact file - * - * @return void - * @throws \dml_exception - * @throws \file_exception - * @throws \moodle_exception - * @throws \stored_file_creation_exception - */ - public function test_artifact_deletion(): void { - // Create test job and link dummy artifact file - $mocks = $this->generateMockQuiz(); - $artifact = $this->generateArtifactFile($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id, 'test.tar.gz'); - $job = ArchiveJob::create( - '70000000-1234-5678-abcd-ef4242424242', - $mocks->course->id, - $mocks->quiz->cmid, - $mocks->quiz->id, - $mocks->user->id, - null, - 'TEST-WS-TOKEN', - $mocks->attempts, - $mocks->settings - ); - $job->link_artifact($artifact->get_id(), hash('sha256', 'foo bar baz')); - - // Delete artifact and ensure that the underlying file was delete correctly - $job->delete_artifact(); - $this->assertNull($job->get_artifact(), 'Job still returned an artifact file after deletion'); - $this->assertFalse($job->has_artifact(), 'Job believes it still has an artifact file'); - $this->assertFalse(get_file_storage()->get_file_by_id($artifact->get_id()), 'Artifact file was not deleted from file storage'); - $this->assertSame(ArchiveJob::STATUS_DELETED, $job->get_status(), 'Job status was not set to deleted'); - } - - /** - * Tests the deletion of expired artifact files - * - * @return void - * @throws \dml_exception - * @throws \file_exception - * @throws \moodle_exception - * @throws \stored_file_creation_exception - */ - public function test_delete_expired_artifacts(): void { - // Create test job that instantly expires and link dummy artifact file - $mocks = $this->generateMockQuiz(); - $artifact = $this->generateArtifactFile($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id, 'test.tar.gz'); - $job = ArchiveJob::create( - '80000000-1234-5678-abcd-ef4242424242', - $mocks->course->id, - $mocks->quiz->cmid, - $mocks->quiz->id, - $mocks->user->id, - -1, - 'TEST-WS-TOKEN', - $mocks->attempts, - $mocks->settings - ); - $job->link_artifact($artifact->get_id(), hash('sha256', 'foo bar baz')); - - // Ensure that the artifact is present - $this->assertTrue($job->has_artifact(), 'Job does not have an artifact file'); - $this->assertSame(1, ArchiveJob::delete_expired_artifacts(), 'Unexpected number of artifacts were reported as deleted'); - $this->assertFalse($job->has_artifact(), 'Job still has an artifact file after deletion'); - $this->assertFalse(get_file_storage()->get_file_by_id($artifact->get_id()), 'Artifact file was not deleted from file storage'); - } - - /** - * Tests that the artifact checksum is null for non-existing artifacts - * - * @return void - * @throws \dml_exception - * @throws \moodle_exception - */ - public function test_artifact_checksum_non_existing(): void { - // Generate data - $mocks = $this->generateMockQuiz(); - $job = ArchiveJob::create( - '99000000-1234-5678-abcd-ef4242424242', - $mocks->course->id, - $mocks->quiz->cmid, - $mocks->quiz->id, - $mocks->user->id, - null, - 'TEST-WS-TOKEN', - $mocks->attempts, - $mocks->settings - ); - - // Check that the artifact checksum is null for non-existing artifacts - $this->assertNull($job->get_artifact_checksum(), 'Artifact checksum was not null for non-existing artifact'); - } - - /** - * Tests that temporary files can be linked to a job - * - * @return void - * @throws \dml_exception - * @throws \file_exception - * @throws \moodle_exception - * @throws \stored_file_creation_exception - */ - public function test_temporary_file_linking(): void { - // Generate data - $mocks = $this->generateMockQuiz(); - $tmpFiles = [ - $this->generateArtifactFile($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id, 'test1.tar.gz'), - $this->generateArtifactFile($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id, 'test2.tar.gz'), - $this->generateArtifactFile($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id, 'test3.tar.gz'), - ]; - - // Create job - $job = ArchiveJob::create( - '90000000-1234-5678-abcd-ef4242424242', - $mocks->course->id, - $mocks->quiz->cmid, - $mocks->quiz->id, - $mocks->user->id, - null, - 'TEST-WS-TOKEN', - $mocks->attempts, - $mocks->settings - ); - - // Ensure no temporary files are linked - $this->assertEmpty($job->get_temporary_files(), 'Job returned temporary files before linking'); - - // Link files and check that they were linked correctly - foreach ($tmpFiles as $tmpFile) { - $job->link_temporary_file($tmpFile->get_pathnamehash()); - } - - $actualTempFiles = $job->get_temporary_files(); - foreach ($tmpFiles as $tmpFile) { - $this->assertEquals($tmpFile, $actualTempFiles[$tmpFile->get_id()], 'Temporary file was not linked correctly'); - } - } - - /** - * Tests that temporary files are deleted properly - * - * @return void - * @throws \dml_exception - * @throws \file_exception - * @throws \moodle_exception - * @throws \stored_file_creation_exception - */ - public function test_temporary_file_deletion(): void { - // Generate data - $mocks = $this->generateMockQuiz(); - $tmpFiles = [ - $this->generateArtifactFile($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id, 'test1.tar.gz'), - $this->generateArtifactFile($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id, 'test2.tar.gz'), - $this->generateArtifactFile($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id, 'test3.tar.gz'), - ]; - - // Create job and link files - $job = ArchiveJob::create( - 'a0000000-1234-5678-abcd-ef4242424242', - $mocks->course->id, - $mocks->quiz->cmid, - $mocks->quiz->id, - $mocks->user->id, - null, - 'TEST-WS-TOKEN', - $mocks->attempts, - $mocks->settings - ); - foreach ($tmpFiles as $tmpFile) { - $job->link_temporary_file($tmpFile->get_pathnamehash()); - } - - // Ensure link state, delete and check - $this->assertCount(3, $job->get_temporary_files(), 'Job did not link all temporary files'); - $job->delete_temporary_files(); - - $this->assertEmpty($job->get_temporary_files(), 'Job still has temporary files after deletion'); - foreach ($tmpFiles as $tmpFile) { - $this->assertFalse(get_file_storage()->get_file_by_id($tmpFile->get_id()), 'Temporary file was not deleted from file storage'); - } - } - - /** - * Test archive filename pattern validation - * - * @dataProvider archive_filename_pattern_data_provider - * - * @param string $pattern Pattern to test - * @param bool $isValid Expected result - * @return void - */ - public function test_archive_filename_pattern_validation(string $pattern, bool $isValid): void { - $this->assertSame( - $isValid, - ArchiveJob::is_valid_archive_filename_pattern($pattern), - 'Archive filename pattern validation failed for pattern "'.$pattern.'"' - ); - } - - /** - * Data provider for test_archive_filename_pattern_validation() - * - * @return array[] Array of test cases - */ - public function archive_filename_pattern_data_provider(): array { - return [ - 'Default pattern' => [ - 'pattern' => 'quiz-archive-${courseshortname}-${courseid}-${quizname}-${quizid}_${date}-${time}', - 'isValid' => true, - ], - 'All allowed variables' => [ - 'pattern' => array_reduce( - ArchiveJob::ARCHIVE_FILENAME_PATTERN_VARIABLES, - function ($carry, $item) { - return $carry.'${'.$item.'}'; - }, - '' - ), - 'isValid' => true, - ], - 'Allowed variables with additional brackets' => [ - 'pattern' => 'quiz-{quizname}_${quizname}-{quizid}_${quizid}', - 'isValid' => true, - ], - 'Invalid variable' => [ - 'pattern' => 'Foo ${foo} Bar ${bar} Baz ${baz}', - 'isValid' => false, - ], - 'Forbidden characters' => [ - 'pattern' => 'quiz-archive: foo!bar', - 'isValid' => false, - ], - 'Only invalid characters' => [ - 'pattern' => '.!', - 'isValid' => false, - ], - 'Dot' => [ - 'pattern' => '.', - 'isValid' => false, - ], - 'Empty pattern' => [ - 'pattern' => '', - 'isValid' => false, - ], - ]; - } - - /** - * Test attempt filename pattern validation - * - * @dataProvider attempt_filename_pattern_data_provider - * - * @param string $pattern Pattern to test - * @param bool $isValid Expected result - * @return void - */ - public function test_attempt_filename_pattern_validation(string $pattern, bool $isValid): void { - $this->assertSame( - $isValid, - ArchiveJob::is_valid_attempt_filename_pattern($pattern), - 'Attempt filename pattern validation failed for pattern "'.$pattern.'"' - ); - } - - /** - * Data provider for test_attempt_filename_pattern_validation() - * - * @return array[] Array of test cases - */ - public function attempt_filename_pattern_data_provider(): array { - return [ - 'Default pattern' => [ - 'pattern' => 'attempt-${attemptid}-${username}_${date}-${time}', - 'isValid' => true, - ], - 'All allowed variables' => [ - 'pattern' => array_reduce( - ArchiveJob::ATTEMPT_FILENAME_PATTERN_VARIABLES, - function ($carry, $item) { - return $carry.'${'.$item.'}'; - }, - '' - ), - 'isValid' => true, - ], - 'Allowed variables with additional brackets' => [ - 'pattern' => 'attempt-{quizname}_${quizname}-{quizid}_${quizid}', - 'isValid' => true, - ], - 'Invalid variable' => [ - 'pattern' => 'Foo ${foo} Bar ${bar} Baz ${baz}', - 'isValid' => false, - ], - 'Forbidden characters' => [ - 'pattern' => 'attempt: foo!bar', - 'isValid' => false, - ], - 'Only invalid characters' => [ - 'pattern' => '.!', - 'isValid' => false, - ], - 'Dot' => [ - 'pattern' => '.', - 'isValid' => false, - ], - 'Empty pattern' => [ - 'pattern' => '', - 'isValid' => false, - ], - ]; - } - -} \ No newline at end of file diff --git a/tests/classes/external/generate_attempt_report_test.php b/tests/classes/external/generate_attempt_report_test.php deleted file mode 100644 index 7d35143..0000000 --- a/tests/classes/external/generate_attempt_report_test.php +++ /dev/null @@ -1,158 +0,0 @@ -. - -/** - * Tests for the generate_attempt_report external service - * - * @package quiz_archiver - * @copyright 2024 Niels Gandraß - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - -namespace quiz_archiver\external; - - -use quiz_archiver\Report; - -/** - * Tests for the generate_attempt_report external service - */ -class generate_attempt_report_test extends \advanced_testcase { - - /** - * Generates a mock quiz to use in the tests - * - * @return \stdClass Created mock objects - */ - protected function generateMockQuiz(): \stdClass { - // Create course, course module and quiz - $this->resetAfterTest(); - - // Prepare user and course - $user = $this->getDataGenerator()->create_user(); - $course = $this->getDataGenerator()->create_course(); - $quiz = $this->getDataGenerator()->create_module('quiz', [ - 'course' => $course->id, - 'grade' => 100.0, - 'sumgrades' => 100 - ]); - - return (object)[ - 'user' => $user, - 'course' => $course, - 'quiz' => $quiz, - ]; - } - - /** - * Generates a set of valid parameters - * - * @param int $courseid Course ID - * @param int $cmid Course module ID - * @param int $quizid Quiz ID - * @param int $attemptid Attempt ID - * @return array Valid request parameters - */ - protected function generateValidRequest(int $courseid, int $cmid, int $quizid, int $attemptid): array { - return [ - 'courseid' => $courseid, - 'cmid' => $cmid, - 'quizid' => $quizid, - 'attemptid' => $attemptid, - 'filenamepattern' => 'test', - 'sections' => array_fill_keys(Report::SECTIONS, true), - 'attachments' => true, - ]; - } - - /** - * Test that users without the required capabilities are rejected - * - * @return void - * @throws \dml_exception - * @throws \moodle_exception - * @throws \DOMException - */ - public function test_capability_requirement(): void { - // Check that a user without the required capability is rejected - $this->expectException(\required_capability_exception::class); - $this->expectExceptionMessageMatches('/.*mod\/quiz_archiver:use_webservice.*/'); - - $mocks = $this->generateMockQuiz(); - $r = $this->generateValidRequest($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id, 1); - generate_attempt_report::execute( - $r['courseid'], - $r['cmid'], - $r['quizid'], - $r['attemptid'], - $r['filenamepattern'], - $r['sections'], - $r['attachments'] - ); - } - - /** - * Verifies webservice parameter validation - * - * @dataProvider parameter_data_provider - * - * @param int $courseid Course ID - * @param int $cmid Course module ID - * @param int $quizid Quiz ID - * @param int $attemptid Attempt ID - * @param string $filenamepattern Filename pattern - * @param array $sections Sections settings array - * @param bool $attachments Whether to include attachments - * @param bool $shouldFail Whether a failure is expected - * @return void - * @throws \DOMException - * @throws \moodle_exception - */ - public function test_parameter_validation( - int $courseid, - int $cmid, - int $quizid, - int $attemptid, - string $filenamepattern, - array $sections, - bool $attachments, - bool $shouldFail - ): void { - if ($shouldFail) { - $this->expectException(\invalid_parameter_exception::class); - } - - try { - generate_attempt_report::execute($courseid, $cmid, $quizid, $attemptid, $filenamepattern, $sections, $attachments); - } catch (\dml_missing_record_exception $e) {} - } - - /** - * Data provider for test_parameter_validation - * - * @return array[] Test data - */ - public function parameter_data_provider(): array { - $mocks = $this->generateMockQuiz(); - $base = $this->generateValidRequest($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id, 1); - return [ - 'Valid' => array_merge($base, ['shouldFail' => false]), - 'Invalid filenamepattern' => array_merge($base, ['filenamepattern' => 'Foo', 'shouldFail' => true]), - 'Invalid sections' => array_merge($base, ['sections' => ['foo' => true], 'shouldFail' => true]), - ]; - } - -} diff --git a/tests/classes/external/get_attempts_metadata_test.php b/tests/classes/external/get_attempts_metadata_test.php deleted file mode 100644 index 20c893d..0000000 --- a/tests/classes/external/get_attempts_metadata_test.php +++ /dev/null @@ -1,143 +0,0 @@ -. - -/** - * Tests for the get_attempts_metadata external service - * - * @package quiz_archiver - * @copyright 2024 Niels Gandraß - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - -namespace tests\classes\external; - - -use quiz_archiver\external\get_attempts_metadata; - -/** - * Tests for the get_attempts_metadata external service - */ -class get_attempts_metadata_test extends \advanced_testcase { - - /** - * Generates a mock quiz to use in the tests - * - * @return \stdClass Created mock objects - */ - protected function generateMockQuiz(): \stdClass { - // Create course, course module and quiz - $this->resetAfterTest(); - - // Prepare user and course - $user = $this->getDataGenerator()->create_user(); - $course = $this->getDataGenerator()->create_course(); - $quiz = $this->getDataGenerator()->create_module('quiz', [ - 'course' => $course->id, - 'grade' => 100.0, - 'sumgrades' => 100 - ]); - - return (object)[ - 'user' => $user, - 'course' => $course, - 'quiz' => $quiz, - ]; - } - - /** - * Generates a set of valid parameters - * - * @param int $courseid Course ID - * @param int $cmid Course module ID - * @param int $quizid Quiz ID - * @return array Valid request parameters - */ - protected function generateValidRequest(int $courseid, int $cmid, int $quizid): array { - return [ - 'courseid' => $courseid, - 'cmid' => $cmid, - 'quizid' => $quizid, - 'attemptids' => [1, 2, 3, 4, 5], - ]; - } - - /** - * Test that users without the required capabilities are rejected - * - * @return void - * @throws \dml_exception - * @throws \moodle_exception - */ - public function test_capability_requirement(): void { - // Check that a user without the required capability is rejected - $this->expectException(\required_capability_exception::class); - $this->expectExceptionMessageMatches('/.*mod\/quiz_archiver:use_webservice.*/'); - - $mocks = $this->generateMockQuiz(); - $r = $this->generateValidRequest($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id); - get_attempts_metadata::execute( - $r['courseid'], - $r['cmid'], - $r['quizid'], - $r['attemptids'] - ); - } - - /** - * Verifies webservice parameter validation - * - * @dataProvider parameter_data_provider - * - * @param int $courseid Course ID - * @param int $cmid Course module ID - * @param int $quizid Quiz ID - * @param array $attemptids Array of attempt IDs - * @param bool $shouldFail Whether a failure is expected - * @return void - * @throws \moodle_exception - */ - public function test_parameter_validation( - int $courseid, - int $cmid, - int $quizid, - array $attemptids, - bool $shouldFail - ): void { - if ($shouldFail) { - $this->expectException(\invalid_parameter_exception::class); - } - - try { - get_attempts_metadata::execute($courseid, $cmid, $quizid, $attemptids); - } catch (\dml_exception $e) {} - } - - /** - * Data provider for test_parameter_validation - * - * @return array[] Test data - */ - public function parameter_data_provider(): array { - $mocks = $this->generateMockQuiz(); - $base = $this->generateValidRequest($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id, 1); - return [ - 'Valid' => array_merge($base, ['shouldFail' => false]), - 'Invalid attemptids (simple)' => array_merge($base, ['attemptids' => ['a'], 'shouldFail' => true]), - 'Invalid attemptids (mixed)' => array_merge($base, ['attemptids' => [1, 2, 3, 4, 5, 'a'], 'shouldFail' => true]), - ]; - } - -} diff --git a/tests/classes/external/process_uploaded_artifact_test.php b/tests/classes/external/process_uploaded_artifact_test.php deleted file mode 100644 index df30392..0000000 --- a/tests/classes/external/process_uploaded_artifact_test.php +++ /dev/null @@ -1,281 +0,0 @@ -. - -/** - * Tests for the process_uploaded_artifact external service - * - * @package quiz_archiver - * @copyright 2024 Niels Gandraß - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - -namespace tests\classes\external; - - -use quiz_archiver\ArchiveJob; -use quiz_archiver\external\process_uploaded_artifact; -use quiz_archiver\FileManager; - -/** - * Tests for the process_uploaded_artifact external service - */ -class process_uploaded_artifact_test extends \advanced_testcase { - - /** - * Generates a mock quiz to use in the tests - * - * @return \stdClass Created mock objects - */ - protected function generateMockQuiz(): \stdClass { - // Create course, course module and quiz - $this->resetAfterTest(); - - // Prepare user and course - $user = $this->getDataGenerator()->create_user(); - $course = $this->getDataGenerator()->create_course(); - $quiz = $this->getDataGenerator()->create_module('quiz', [ - 'course' => $course->id, - 'grade' => 100.0, - 'sumgrades' => 100 - ]); - - return (object)[ - 'user' => $user, - 'course' => $course, - 'quiz' => $quiz, - ]; - } - - /** - * Generates a set of valid parameters - * - * @param string $jobid Job ID - * @param int $cmid Course module ID - * @param int $userid User ID - * @return array Valid request parameters - */ - protected function generateValidRequest(string $jobid, int $cmid, int $userid): array { - return [ - 'jobid' => $jobid, - 'artifact_component' => FileManager::COMPONENT_NAME, - 'artifact_contextid' => \context_module::instance($cmid)->id, - 'artifact_userid' => $userid, - 'artifact_filearea' => FileManager::TEMP_FILEAREA_NAME, - 'artifact_filename' => 'artifact.tar.gz', - 'artifact_filepath' => '/', - 'artifact_itemid' => 1, - 'artifact_sha256sum' => '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', - ]; - } - - /** - * Test that users without the required capabilities are rejected - * - * @return void - * @throws \dml_exception - * @throws \moodle_exception - */ - public function test_capability_requirement(): void { - // Check that a user without the required capability is rejected - $this->expectException(\required_capability_exception::class); - $this->expectExceptionMessageMatches('/.*mod\/quiz_archiver:use_webservice.*/'); - - // Create job - $mocks = $this->generateMockQuiz(); - $job = ArchiveJob::create( - '10000000-1234-5678-abcd-ef4242424242', - $mocks->course->id, - $mocks->quiz->cmid, - $mocks->quiz->id, - $mocks->user->id, - null, - 'TEST-WS-TOKEN', - [], - [] - ); - - // Execute test call - $_GET['wstoken'] = 'TEST-WS-TOKEN'; - $r = $this->generateValidRequest($job->get_jobid(), $mocks->quiz->cmid, $mocks->user->id); - process_uploaded_artifact::execute( - $r['jobid'], - $r['artifact_component'], - $r['artifact_contextid'], - $r['artifact_userid'], - $r['artifact_filearea'], - $r['artifact_filename'], - $r['artifact_filepath'], - $r['artifact_itemid'], - $r['artifact_sha256sum'] - ); - } - - /** - * Verifies webservice parameter validation - * - * @dataProvider parameter_data_provider - * - * @param string $jobid Job ID - * @param string $artifact_component Component name - * @param int $artifact_contextid Context ID - * @param int $artifact_userid User ID - * @param string $artifact_filearea File area name - * @param string $artifact_filename File name - * @param string $artifact_filepath File path - * @param int $artifact_itemid Item ID - * @param string $artifact_sha256sum SHA256 checksum - * @param bool $shouldFail Whether a failure is expected - * @return void - * @throws \coding_exception - * @throws \dml_exception - * @throws \invalid_parameter_exception - * @throws \required_capability_exception - */ - public function test_parameter_validation( - string $jobid, - string $artifact_component, - int $artifact_contextid, - int $artifact_userid, - string $artifact_filearea, - string $artifact_filename, - string $artifact_filepath, - int $artifact_itemid, - string $artifact_sha256sum, - bool $shouldFail - ): void { - if ($shouldFail) { - $this->expectException(\invalid_parameter_exception::class); - } - - process_uploaded_artifact::execute( - $jobid, - $artifact_component, - $artifact_contextid, - $artifact_userid, - $artifact_filearea, - $artifact_filename, - $artifact_filepath, - $artifact_itemid, - $artifact_sha256sum - ); - } - - /** - * Data provider for test_parameter_validation - * - * @return array[] Test data - */ - public function parameter_data_provider(): array { - $mocks = $this->generateMockQuiz(); - $base = $this->generateValidRequest('xxx', $mocks->quiz->cmid, $mocks->user->id); - return [ - 'Valid' => array_merge($base, ['shouldFail' => false]), - 'Invalid jobid' => array_merge($base, ['jobid' => 'Foo', 'shouldFail' => true]), - 'Invalid artifact_component' => array_merge($base, ['artifact_component' => 'Foo', 'shouldFail' => true]), - 'Invalid artifact_filearea' => array_merge($base, ['artifact_filearea' => 'Foo', 'shouldFail' => true]), - 'Invalid artifact_filename' => array_merge($base, ['artifact_filename' => 'Foo', 'shouldFail' => true]), - 'Invalid artifact_filepath' => array_merge($base, ['artifact_filepath' => 'Foo', 'shouldFail' => true]), - 'Invalid artifact_sha256sum' => array_merge($base, ['artifact_sha256sum' => 'Foo', 'shouldFail' => true]), - ]; - } - - /** - * Test that completed jobs reject further artifact uploads - * - * @return void - * @throws \coding_exception - * @throws \dml_exception - * @throws \invalid_parameter_exception - * @throws \moodle_exception - * @throws \required_capability_exception - */ - public function test_rejection_of_artifacts_for_complete_jobs(): void { - // Create job - $mocks = $this->generateMockQuiz(); - $job = ArchiveJob::create( - '20000000-1234-5678-abcd-ef4242424242', - $mocks->course->id, - $mocks->quiz->cmid, - $mocks->quiz->id, - $mocks->user->id, - null, - 'TEST-WS-TOKEN', - [], - [], - ArchiveJob::STATUS_FINISHED - ); - - // Execute test call - $_GET['wstoken'] = 'TEST-WS-TOKEN'; - $r = $this->generateValidRequest($job->get_jobid(), $mocks->quiz->cmid, $mocks->user->id); - $this->assertSame(['status' => 'E_NO_ARTIFACT_UPLOAD_EXPECTED'], process_uploaded_artifact::execute( - $r['jobid'], - $r['artifact_component'], - $r['artifact_contextid'], - $r['artifact_userid'], - $r['artifact_filearea'], - $r['artifact_filename'], - $r['artifact_filepath'], - $r['artifact_itemid'], - $r['artifact_sha256sum'] - )); - } - - /** - * Test that missing files are reported correctly - * - * @return void - * @throws \coding_exception - * @throws \dml_exception - * @throws \invalid_parameter_exception - * @throws \moodle_exception - * @throws \required_capability_exception - */ - public function test_invalid_file_metadata(): void { - // Create job - $mocks = $this->generateMockQuiz(); - $job = ArchiveJob::create( - '30000000-1234-5678-abcd-ef4242424242', - $mocks->course->id, - $mocks->quiz->cmid, - $mocks->quiz->id, - $mocks->user->id, - null, - 'TEST-WS-TOKEN', - [], - [] - ); - - // Gain access - $_GET['wstoken'] = 'TEST-WS-TOKEN'; - $this->setAdminUser(); - - // Execute test call - $r = $this->generateValidRequest($job->get_jobid(), $mocks->quiz->cmid, $mocks->user->id); - $this->assertSame(['status' => 'E_UPLOADED_ARTIFACT_NOT_FOUND'], process_uploaded_artifact::execute( - $r['jobid'], - $r['artifact_component'], - $r['artifact_contextid'], - $r['artifact_userid'], - $r['artifact_filearea'], - $r['artifact_filename'], - $r['artifact_filepath'], - $r['artifact_itemid'], - $r['artifact_sha256sum'] - )); - } - -} diff --git a/tests/classes/external/update_job_status_test.php b/tests/classes/external/update_job_status_test.php deleted file mode 100644 index debcfc1..0000000 --- a/tests/classes/external/update_job_status_test.php +++ /dev/null @@ -1,238 +0,0 @@ -. - -/** - * Tests for the update_job_status external service - * - * @package quiz_archiver - * @copyright 2024 Niels Gandraß - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - -namespace quiz_archiver\external; - - -use quiz_archiver\ArchiveJob; - -/** - * Tests for the update_job_status external service - */ -class update_job_status_test extends \advanced_testcase { - - /** - * Generates a mock quiz to use in the tests - * - * @return \stdClass Created mock objects - */ - protected function generateMockQuiz(): \stdClass { - // Create course, course module and quiz - $this->resetAfterTest(); - - // Prepare user and course - $user = $this->getDataGenerator()->create_user(); - $course = $this->getDataGenerator()->create_course(); - $quiz = $this->getDataGenerator()->create_module('quiz', [ - 'course' => $course->id, - 'grade' => 100.0, - 'sumgrades' => 100 - ]); - - return (object)[ - 'user' => $user, - 'course' => $course, - 'quiz' => $quiz, - ]; - } - - /** - * Test that users without the required capabilities are rejected - * - * @return void - * @throws \dml_exception - * @throws \moodle_exception - */ - public function test_capability_requirement(): void { - // Create mock quiz and job - $mocks = $this->generateMockQuiz(); - $job = ArchiveJob::create( - '00000000-1234-5678-abcd-ef4242424242', - $mocks->course->id, - $mocks->quiz->cmid, - $mocks->quiz->id, - $mocks->user->id, - null, - 'TEST-WS-TOKEN', - [], - [], - ); - $_GET['wstoken'] = 'TEST-WS-TOKEN'; - - // Check that a user without the required capability is rejected - $this->expectException(\required_capability_exception::class); - $this->expectExceptionMessageMatches('/.*mod\/quiz_archiver:use_webservice.*/'); - update_job_status::execute($job->get_jobid(), ArchiveJob::STATUS_UNINITIALIZED); - } - - /** - * Tests that webservice tokens are validated against the requested job - * - * @return void - * @throws \coding_exception - * @throws \dml_exception - * @throws \invalid_parameter_exception - * @throws \moodle_exception - * @throws \required_capability_exception - */ - public function test_wstoken_validation(): void { - // Gain access to webservice - $this->setAdminUser(); - - // Create mock quiz and job - $mocks = $this->generateMockQuiz(); - $job = ArchiveJob::create( - '00000000-1234-5678-abcd-ef4242424242', - $mocks->course->id, - $mocks->quiz->cmid, - $mocks->quiz->id, - $mocks->user->id, - null, - 'TEST-WS-TOKEN-VALID', - [], - [], - ); - - // Check that a valid token is accepted - $_GET['wstoken'] = 'TEST-WS-TOKEN-VALID'; - $this->assertSame( - ['status' => 'OK'], - update_job_status::execute($job->get_jobid(), ArchiveJob::STATUS_UNINITIALIZED), - 'Valid token was rejected' - ); - - // Check that an invalid token is rejected - $_GET['wstoken'] = 'TEST-WS-TOKEN-INVALID'; - $this->assertSame( - ['status' => 'E_ACCESS_DENIED'], - update_job_status::execute($job->get_jobid(), ArchiveJob::STATUS_UNINITIALIZED), - 'Invalid token was accepted' - ); - } - - /** - * Verifies webservice parameter validation - * - * @dataProvider parameter_data_provider - * - * @param string $jobid Raw jobid parameter - * @param string $status Raw status parameter - * @param bool $shouldFail Whether a failure is expected - * @return void - * @throws \coding_exception - * @throws \invalid_parameter_exception - * @throws \required_capability_exception - */ - public function test_parameter_validation(string $jobid, string $status, bool $shouldFail): void { - if ($shouldFail) { - $this->expectException(\invalid_parameter_exception::class); - } - - update_job_status::execute($jobid, $status); - } - - /** - * Data provider for test_parameter_validation - * - * @return array[] Test data - */ - public function parameter_data_provider(): array { - return [ - 'Valid' => ['jobid' => '00000000-1234-5678-abcd-ef4242424242', 'status' => ArchiveJob::STATUS_UNINITIALIZED, 'shouldFail' => false], - 'Invalid jobid' => ['jobid' => 'Foo', 'status' => ArchiveJob::STATUS_UNINITIALIZED, 'shouldFail' => true], - 'Invalid status' => ['jobid' => '00000000-1234-5678-abcd-ef4242424242', 'status' => 'Bar', 'shouldFail' => true], - 'Invalid jobid and status' => ['jobid' => 'Foo', 'status' => 'Bar', 'shouldFail' => true], - ]; - } - - /** - * Test updating a valid job - * - * @dataProvider job_status_data_provider - * - * @param string $originStatus Status to transition from - * @param string $targetStatus Status to transition to - * @param array $expected Expected result - * @return void - * @throws \coding_exception - * @throws \dml_exception - * @throws \invalid_parameter_exception - * @throws \moodle_exception - * @throws \required_capability_exception - */ - public function test_update_job_status(string $originStatus, string $targetStatus, array $expected) { - // Gain privileges - $this->setAdminUser(); - $_GET['wstoken'] = 'TEST-WS-TOKEN'; - - // Create mock quiz and job - $mocks = $this->generateMockQuiz(); - $job = ArchiveJob::create( - '00000000-1234-5678-abcd-ef4242424242', - $mocks->course->id, - $mocks->quiz->cmid, - $mocks->quiz->id, - $mocks->user->id, - null, - 'TEST-WS-TOKEN', - [], - [], - $originStatus - ); - - // Ensure job is in the expected state - $this->assertSame($originStatus, $job->get_status()); - - // Execute the external function and check the result - $result = update_job_status::execute( - $job->get_jobid(), - $targetStatus - ); - $this->assertSame($expected, $result, 'Invalid webservice answer'); - } - - /** - * Data provider for test_update_job_status - * - * @return array[] Test data - */ - public function job_status_data_provider(): array { - return [ - 'Status: UNKNOWN -> UNINITIALIZED' => ['originStatus' => ArchiveJob::STATUS_UNKNOWN, 'targetStatus' => ArchiveJob::STATUS_UNINITIALIZED, 'expected' => ['status' => 'OK']], - 'Status: UNINITIALIZED -> AWAITING_PROCESSING' => ['originStatus' => ArchiveJob::STATUS_UNINITIALIZED, 'targetStatus' => ArchiveJob::STATUS_AWAITING_PROCESSING, 'expected' => ['status' => 'OK']], - 'Status: UNINITIALIZED -> FINISHED' => ['originStatus' => ArchiveJob::STATUS_UNINITIALIZED, 'targetStatus' => ArchiveJob::STATUS_FINISHED, 'expected' => ['status' => 'OK']], - 'Status: AWAITING_PROCESSING -> RUNNING' => ['originStatus' => ArchiveJob::STATUS_AWAITING_PROCESSING, 'targetStatus' => ArchiveJob::STATUS_RUNNING, 'expected' => ['status' => 'OK']], - 'Status: RUNNING -> FINISHED' => ['originStatus' => ArchiveJob::STATUS_RUNNING, 'targetStatus' => ArchiveJob::STATUS_FINISHED, 'expected' => ['status' => 'OK']], - 'Status: RUNNING -> FAILED' => ['originStatus' => ArchiveJob::STATUS_RUNNING, 'targetStatus' => ArchiveJob::STATUS_FAILED, 'expected' => ['status' => 'OK']], - 'Status: RUNNING -> TIMEOUT' => ['originStatus' => ArchiveJob::STATUS_RUNNING, 'targetStatus' => ArchiveJob::STATUS_TIMEOUT, 'expected' => ['status' => 'OK']], - 'Status: FINISHED -> DELETED' => ['originStatus' => ArchiveJob::STATUS_FINISHED, 'targetStatus' => ArchiveJob::STATUS_DELETED, 'expected' => ['status' => 'E_JOB_ALREADY_COMPLETED']], - 'Status: FINISHED -> RUNNING' => ['originStatus' => ArchiveJob::STATUS_FINISHED, 'targetStatus' => ArchiveJob::STATUS_RUNNING, 'expected' => ['status' => 'E_JOB_ALREADY_COMPLETED']], - 'Status: FINISHED -> FAILED' => ['originStatus' => ArchiveJob::STATUS_FINISHED, 'targetStatus' => ArchiveJob::STATUS_FAILED, 'expected' => ['status' => 'E_JOB_ALREADY_COMPLETED']], - 'Status: FINISHED -> TIMEOUT' => ['originStatus' => ArchiveJob::STATUS_FINISHED, 'targetStatus' => ArchiveJob::STATUS_TIMEOUT, 'expected' => ['status' => 'E_JOB_ALREADY_COMPLETED']], - 'Status: FINISHED -> UNINITIALIZED' => ['originStatus' => ArchiveJob::STATUS_FINISHED, 'targetStatus' => ArchiveJob::STATUS_UNINITIALIZED, 'expected' => ['status' => 'E_JOB_ALREADY_COMPLETED']], - 'Status: FAILED -> DELETED' => ['originStatus' => ArchiveJob::STATUS_FAILED, 'targetStatus' => ArchiveJob::STATUS_DELETED, 'expected' => ['status' => 'E_JOB_ALREADY_COMPLETED']], - ]; - } - -} diff --git a/tests/external/generate_attempt_report_test.php b/tests/external/generate_attempt_report_test.php new file mode 100644 index 0000000..e20cb20 --- /dev/null +++ b/tests/external/generate_attempt_report_test.php @@ -0,0 +1,247 @@ +. + +/** + * Tests for the generate_attempt_report external service + * + * @package quiz_archiver + * @copyright 2024 Niels Gandraß + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace quiz_archiver\external; + +use quiz_archiver\ArchiveJob; +use quiz_archiver\Report; + +/** + * Tests for the generate_attempt_report external service + */ +final class generate_attempt_report_test extends \advanced_testcase { + + /** + * Returns the data generator for the quiz_archiver plugin + * + * @return \quiz_archiver_generator The data generator for the quiz_archiver plugin + */ + // @codingStandardsIgnoreLine + public static function getDataGenerator(): \quiz_archiver_generator { + return parent::getDataGenerator()->get_plugin_generator('quiz_archiver'); + } + + /** + * Generates a set of valid parameters + * + * @param int $courseid Course ID + * @param int $cmid Course module ID + * @param int $quizid Quiz ID + * @param int $attemptid Attempt ID + * @return array Valid request parameters + */ + protected function generate_valid_request(int $courseid, int $cmid, int $quizid, int $attemptid): array { + return [ + 'courseid' => $courseid, + 'cmid' => $cmid, + 'quizid' => $quizid, + 'attemptid' => $attemptid, + 'filenamepattern' => 'test', + 'sections' => array_fill_keys(Report::SECTIONS, true), + 'attachments' => true, + ]; + } + + /** + * Tests that the parameter spec is specified correctly and produces no exception. + * + * @covers \quiz_archiver\external\generate_attempt_report::execute_parameters + * + * @return void + */ + public function test_assure_execute_parameter_spec(): void { + $this->resetAfterTest(); + $this->assertInstanceOf( + \core_external\external_function_parameters::class, + generate_attempt_report::execute_parameters(), + 'The execute_parameters() method should return an external_function_parameters.' + ); + } + + /** + * Tests that the return parameters are specified correctly and produce no exception. + * + * @covers \quiz_archiver\external\generate_attempt_report::execute_returns + * + * @return void + */ + public function test_assure_return_parameter_spec(): void { + $this->assertInstanceOf( + \core_external\external_description::class, + generate_attempt_report::execute_returns(), + 'The execute_returns() method should return an external_description.' + ); + } + + /** + * Test that users without the required capabilities are rejected + * + * @covers \quiz_archiver\external\generate_attempt_report::execute + * + * @return void + * @throws \dml_exception + * @throws \moodle_exception + * @throws \DOMException + */ + public function test_capability_requirement(): void { + // Check that a user without the required capability is rejected. + $this->expectException(\required_capability_exception::class); + $this->expectExceptionMessageMatches('/.*mod\/quiz_archiver:use_webservice.*/'); + + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $r = $this->generate_valid_request($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id, 1); + generate_attempt_report::execute( + $r['courseid'], + $r['cmid'], + $r['quizid'], + $r['attemptid'], + $r['filenamepattern'], + $r['sections'], + $r['attachments'] + ); + } + + /** + * Test web service part of processing of a valid request + * + * @covers \quiz_archiver\external\generate_attempt_report::execute + * + * @return void + * @throws \DOMException + * @throws \coding_exception + * @throws \dml_exception + * @throws \dml_transaction_exception + * @throws \moodle_exception + */ + public function test_execute(): void { + // Create mock quiz and archive job. + $this->resetAfterTest(); + $this->setAdminUser(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $jobid = '10000000-0000-0000-0000-0123456789ab'; + $wstoken = 'TEST-WS-TOKEN-1'; + ArchiveJob::create( + $jobid, + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + null, + 'TEST-WS-TOKEN-1', + $mocks->attempts, + $mocks->settings + ); + + // Create a valid request. + $r = $this->generate_valid_request($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id, 1); + $_GET['wstoken'] = $wstoken; + + // Execute the request. + $this->expectException(\invalid_parameter_exception::class); + $this->expectExceptionMessage('No attempt with given attemptid found'); + generate_attempt_report::execute( + $r['courseid'], + $r['cmid'], + $r['quizid'], + $r['attemptid'], + $r['filenamepattern'], + $r['sections'], + $r['attachments'] + ); + } + + /** + * Verifies webservice parameter validation + * + * @dataProvider parameter_validation_data_provider + * @covers \quiz_archiver\external\generate_attempt_report::execute + * @covers \quiz_archiver\external\generate_attempt_report::validate_parameters + * + * @param string $invalidparameterkey Key of the parameter to invalidate + * @return void + * @throws \DOMException + * @throws \dml_exception + * @throws \dml_transaction_exception + * @throws \moodle_exception + */ + public function test_parameter_validation(string $invalidparameterkey): void { + // Create mock quiz and archive job. + $this->resetAfterTest(); + $this->setAdminUser(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $jobid = '20000000-0000-0000-0000-0123456789ab'; + $wstoken = 'TEST-WS-TOKEN-2'; + ArchiveJob::create( + $jobid, + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + null, + 'TEST-WS-TOKEN-2', + $mocks->attempts, + $mocks->settings + ); + + // Create a request. + $r = $this->generate_valid_request( + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->attempts[0]->attemptid + ); + $_GET['wstoken'] = $wstoken; + + // Execute the request. + $this->expectException(\invalid_parameter_exception::class); + $this->expectExceptionMessageMatches('/.*'.$invalidparameterkey.'.*/'); + generate_attempt_report::execute( + $invalidparameterkey == 'courseid' ? 0 : $r['courseid'], + $invalidparameterkey == 'cmid' ? 0 : $r['cmid'], + $invalidparameterkey == 'quizid' ? 0 : $r['quizid'], + $invalidparameterkey == 'attemptid' ? 0 : $r['attemptid'], + $invalidparameterkey == 'filename pattern' ? 'invalid-${pattern' : $r['filenamepattern'], + $invalidparameterkey == 'sections' ? [] : $r['sections'], + $r['attachments'] + ); + } + + /** + * Data provider for test_parameter_validation + * + * @return array[] Test data + */ + public static function parameter_validation_data_provider(): array { + return [ + 'Invalid courseid' => ['courseid'], + 'Invalid cmid' => ['cmid'], + 'Invalid quizid' => ['quizid'], + 'Invalid attemptid' => ['attemptid'], + 'Invalid filenamepattern' => ['filename pattern'], + 'Invalid sections' => ['sections'], + ]; + } + +} diff --git a/tests/external/get_attempts_metadata_test.php b/tests/external/get_attempts_metadata_test.php new file mode 100644 index 0000000..354153e --- /dev/null +++ b/tests/external/get_attempts_metadata_test.php @@ -0,0 +1,270 @@ +. + +/** + * Tests for the get_attempts_metadata external service + * + * @package quiz_archiver + * @copyright 2024 Niels Gandraß + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace quiz_archiver\external; + +use quiz_archiver\ArchiveJob; + +/** + * Tests for the get_attempts_metadata external service + */ +final class get_attempts_metadata_test extends \advanced_testcase { + + /** + * Returns the data generator for the quiz_archiver plugin + * + * @return \quiz_archiver_generator The data generator for the quiz_archiver plugin + */ + // @codingStandardsIgnoreLine + public static function getDataGenerator(): \quiz_archiver_generator { + return parent::getDataGenerator()->get_plugin_generator('quiz_archiver'); + } + + /** + * Generates a set of valid parameters + * + * @param int $courseid Course ID + * @param int $cmid Course module ID + * @param int $quizid Quiz ID + * @return array Valid request parameters + */ + protected function generate_valid_request(int $courseid, int $cmid, int $quizid): array { + return [ + 'courseid' => $courseid, + 'cmid' => $cmid, + 'quizid' => $quizid, + 'attemptids' => [1, 2, 3, 4, 5], + ]; + } + + /** + * Tests that the parameter spec is specified correctly and produces no exception. + * + * @covers \quiz_archiver\external\get_attempts_metadata::execute_parameters + * + * @return void + */ + public function test_assure_execute_parameter_spec(): void { + $this->resetAfterTest(); + $this->assertInstanceOf( + \core_external\external_function_parameters::class, + get_attempts_metadata::execute_parameters(), + 'The execute_parameters() method should return an external_function_parameters.' + ); + } + + /** + * Tests that the return parameters are specified correctly and produce no exception. + * + * @covers \quiz_archiver\external\get_attempts_metadata::execute_returns + * + * @return void + */ + public function test_assure_return_parameter_spec(): void { + $this->assertInstanceOf( + \core_external\external_description::class, + get_attempts_metadata::execute_returns(), + 'The execute_returns() method should return an external_description.' + ); + } + + /** + * Test that users without the required capabilities are rejected + * + * @covers \quiz_archiver\external\get_attempts_metadata::execute + * + * @return void + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_capability_requirement(): void { + // Check that a user without the required capability is rejected. + $this->expectException(\required_capability_exception::class); + $this->expectExceptionMessageMatches('/.*mod\/quiz_archiver:use_webservice.*/'); + + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $r = $this->generate_valid_request($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id); + get_attempts_metadata::execute( + $r['courseid'], + $r['cmid'], + $r['quizid'], + $r['attemptids'] + ); + } + + /** + * Tests that only web service tokens with read access to a job can request + * attempt metadata + * + * @covers \quiz_archiver\external\get_attempts_metadata::execute + * + * @return void + * @throws \coding_exception + * @throws \dml_exception + * @throws \invalid_parameter_exception + * @throws \moodle_exception + * @throws \required_capability_exception + */ + public function test_wstoken_write_access_check(): void { + // Create job. + $this->resetAfterTest(); + $this->setAdminUser(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + ArchiveJob::create( + '11000000-1234-5678-abcd-ef4242424242', + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + null, + 'TEST-WS-TOKEN', + [], + [] + ); + + // Execute test call. + $_GET['wstoken'] = 'INVALID-WS-TOKEN'; + $r = $this->generate_valid_request($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id); + $res = get_attempts_metadata::execute( + $r['courseid'], + $r['cmid'], + $r['quizid'], + $r['attemptids'], + ); + + // Ensure that the access was denied. + $this->assertSame(['status' => 'E_ACCESS_DENIED'], $res, 'Websertice token without access rights was falsely accepted'); + } + + /** + * Test web service part of processing of a valid request + * + * @covers \quiz_archiver\external\get_attempts_metadata::execute + * + * @return void + * @throws \coding_exception + * @throws \dml_exception + * @throws \dml_transaction_exception + * @throws \moodle_exception + */ + public function test_execute(): void { + // Create mock quiz and archive job. + $this->resetAfterTest(); + $this->setAdminUser(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $jobid = '10000000-0000-0000-0000-0123456789ab'; + $wstoken = 'TEST-WS-TOKEN-1'; + ArchiveJob::create( + $jobid, + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + null, + 'TEST-WS-TOKEN-1', + $mocks->attempts, + $mocks->settings + ); + + // Create a valid request. + $r = $this->generate_valid_request($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id); + $_GET['wstoken'] = $wstoken; + + // Execute the request. + $res = get_attempts_metadata::execute( + $r['courseid'], + $r['cmid'], + $r['quizid'], + $r['attemptids'], + ); + $this->assertSame('OK', $res['status'], 'The status should be OK.'); + $this->assertArrayHasKey('attempts', $res, 'The response should contain an attempts key.'); + } + + /** + * Verifies webservice parameter validation + * + * @dataProvider parameter_validation_data_provider + * @covers \quiz_archiver\external\get_attempts_metadata::execute + * @covers \quiz_archiver\external\get_attempts_metadata::validate_parameters + * + * @param string $invalidparameterkey Key of the parameter to invalidate + * @return void + * @throws \dml_exception + * @throws \dml_transaction_exception + * @throws \moodle_exception + */ + public function test_parameter_validation(string $invalidparameterkey): void { + // Create mock quiz and archive job. + $this->resetAfterTest(); + $this->setAdminUser(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $jobid = '20000000-0000-0000-0000-0123456789ab'; + $wstoken = 'TEST-WS-TOKEN-2'; + ArchiveJob::create( + $jobid, + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + null, + 'TEST-WS-TOKEN-2', + $mocks->attempts, + $mocks->settings + ); + + // Create a request. + $r = $this->generate_valid_request( + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id + ); + $_GET['wstoken'] = $wstoken; + + // Execute the request. + $this->expectException(\invalid_parameter_exception::class); + $this->expectExceptionMessageMatches('/.*'.$invalidparameterkey.'.*/'); + get_attempts_metadata::execute( + $invalidparameterkey == 'courseid' ? 0 : $r['courseid'], + $invalidparameterkey == 'cmid' ? 0 : $r['cmid'], + $invalidparameterkey == 'quizid' ? 0 : $r['quizid'], + $r['attemptids'] + ); + } + + /** + * Data provider for test_parameter_validation + * + * @return array[] Test data + */ + public static function parameter_validation_data_provider(): array { + return [ + 'Invalid courseid' => ['courseid'], + 'Invalid cmid' => ['cmid'], + 'Invalid quizid' => ['quizid'], + ]; + } + +} diff --git a/tests/classes/external/get_backup_status_test.php b/tests/external/get_backup_status_test.php similarity index 56% rename from tests/classes/external/get_backup_status_test.php rename to tests/external/get_backup_status_test.php index 7c60132..0a81203 100644 --- a/tests/classes/external/get_backup_status_test.php +++ b/tests/external/get_backup_status_test.php @@ -22,53 +22,40 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -namespace tests\classes\external; +namespace quiz_archiver\external; use quiz_archiver\ArchiveJob; -use quiz_archiver\external\get_backup_status; /** * Tests for the get_backup_status external service */ -class get_backup_status_test extends \advanced_testcase { +final class get_backup_status_test extends \advanced_testcase { /** - * Generates a mock quiz to use in the tests + * Returns the data generator for the quiz_archiver plugin * - * @return \stdClass Created mock objects + * @return \quiz_archiver_generator The data generator for the quiz_archiver plugin */ - protected function generateMockQuiz(): \stdClass { - // Create course, course module and quiz - $this->resetAfterTest(); - - // Prepare user and course - $user = $this->getDataGenerator()->create_user(); - $course = $this->getDataGenerator()->create_course(); - $quiz = $this->getDataGenerator()->create_module('quiz', [ - 'course' => $course->id, - 'grade' => 100.0, - 'sumgrades' => 100 - ]); - - return (object)[ - 'user' => $user, - 'course' => $course, - 'quiz' => $quiz, - ]; + // @codingStandardsIgnoreLine + public static function getDataGenerator(): \quiz_archiver_generator { + return parent::getDataGenerator()->get_plugin_generator('quiz_archiver'); } /** * Test that users without the required capabilities are rejected * + * @covers \quiz_archiver\external\get_backup_status::execute + * * @return void * @throws \dml_exception * @throws \moodle_exception * @throws \DOMException */ public function test_capability_requirement(): void { - // Create job - $mocks = $this->generateMockQuiz(); + // Create job. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); $job = ArchiveJob::create( '10000000-1234-5678-abcd-ef4242424242', $mocks->course->id, @@ -82,50 +69,70 @@ public function test_capability_requirement(): void { ); $_GET['wstoken'] = 'TEST-WS-TOKEN'; - // Check that a user without the required capability is rejected + // Check that a user without the required capability is rejected. $this->expectException(\required_capability_exception::class); $this->expectExceptionMessageMatches('/.*mod\/quiz_archiver:use_webservice.*/'); get_backup_status::execute($job->get_jobid(), 'f1d2d2f924e986ac86fdf7b36c94bcdf32beec15'); } + /** + * Tests that the parameter spec is specified correctly and produces no exception. + * + * @covers \quiz_archiver\external\get_backup_status::execute_parameters + * + * @return void + */ + public function test_assure_execute_parameter_spec(): void { + $this->resetAfterTest(); + $this->assertInstanceOf( + \core_external\external_function_parameters::class, + get_backup_status::execute_parameters(), + 'The execute_parameters() method should return an external_function_parameters.' + ); + } + + /** + * Tests that the return parameters are specified correctly and produce no exception. + * + * @covers \quiz_archiver\external\get_backup_status::execute_returns + * + * @return void + */ + public function test_assure_return_parameter_spec(): void { + $this->assertInstanceOf( + \core_external\external_description::class, + get_backup_status::execute_returns(), + 'The execute_returns() method should return an external_description.' + ); + } /** * Verifies webservice parameter validation * * @dataProvider parameter_data_provider + * @covers \quiz_archiver\external\get_backup_status::execute + * @covers \quiz_archiver\external\get_backup_status::validate_parameters * - * @param string $jobid Job ID - * @param string $backupid Backup ID - * @param bool $shouldFail Whether a failure is expected + * @param string|null $jobid Job ID to check + * @param string|null $backupid Backup ID to check + * @param bool $shouldfail Whether a failure is expected * @return void * @throws \coding_exception + * @throws \dml_exception * @throws \invalid_parameter_exception * @throws \required_capability_exception */ public function test_parameter_validation( - string $jobid, - string $backupid, - bool $shouldFail + ?string $jobid, + ?string $backupid, + bool $shouldfail ): void { - if ($shouldFail) { - $this->expectException(\invalid_parameter_exception::class); - } - - try { - get_backup_status::execute($jobid, $backupid); - } catch (\dml_exception $e) {} - } + // Gain webservice permission. + $this->setAdminUser(); - /** - * Data provider for test_parameter_validation - * - * @return array[] Test data - * @throws \dml_exception - * @throws \moodle_exception - */ - public function parameter_data_provider(): array { - // Create job - $mocks = $this->generateMockQuiz(); + // Create mock quiz. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); $job = ArchiveJob::create( '20000000-1234-5678-abcd-ef4242424242', $mocks->course->id, @@ -137,21 +144,50 @@ public function parameter_data_provider(): array { [], [] ); - $base = [ - 'jobid' => $job->get_jobid(), - 'backupid' => 'f1d2d2f924e986ac86fdf7b36c94bcdf32beec15', - ]; + $_GET['wstoken'] = 'TEST-WS-TOKEN'; + + if ($shouldfail) { + $this->expectException(\invalid_parameter_exception::class); + } + get_backup_status::execute( + $jobid === null ? $job->get_jobid() : $jobid, + $backupid === null ? 'f1d2d2f924e986ac86fdf7b36c94bcdf32beec15' : $backupid + ); + } + + /** + * Data provider for test_parameter_validation + * + * @return array[] Test data + * @throws \dml_exception + * @throws \moodle_exception + */ + public static function parameter_data_provider(): array { return [ - 'Valid' => array_merge($base, ['shouldFail' => false]), - 'Invalid jobid' => array_merge($base, ['jobid' => 'Foo', 'shouldFail' => true]), - 'Invalid backupid' => array_merge($base, ['backupid' => 'Bar', 'shouldFail' => true]), + 'Valid' => array_merge([ + 'jobid' => null, + 'backupid' => null, + 'shouldfail' => false, + ]), + 'Invalid jobid' => array_merge([ + 'jobid' => 'Foo', + 'backupid' => null, + 'shouldfail' => true, + ]), + 'Invalid backupid' => array_merge([ + 'jobid' => null, + 'backupid' => 'Bar', + 'shouldfail' => true, + ]), ]; } /** * Test wstoken validation * + * @covers \quiz_archiver\external\get_backup_status::execute + * * @return void * @throws \coding_exception * @throws \dml_exception @@ -160,11 +196,12 @@ public function parameter_data_provider(): array { * @throws \required_capability_exception */ public function test_wstoken_access_check(): void { - // Gain webservice permission + // Gain webservice permission. $this->setAdminUser(); - // Create job - $mocks = $this->generateMockQuiz(); + // Create job. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); $job = ArchiveJob::create( '30000000-1234-5678-abcd-ef4242424242', $mocks->course->id, @@ -177,7 +214,7 @@ public function test_wstoken_access_check(): void { [] ); - // Check that correct wstoken allows access + // Check that correct wstoken allows access. $_GET['wstoken'] = 'TEST-WS-TOKEN-VALID'; $this->assertSame( ['status' => 'E_BACKUP_NOT_FOUND'], @@ -185,7 +222,7 @@ public function test_wstoken_access_check(): void { 'Valid wstoken was falsely rejected' ); - // Check that incorrect wstoken is rejected + // Check that incorrect wstoken is rejected. $_GET['wstoken'] = 'TEST-WS-TOKEN-INVALID'; $this->assertSame( ['status' => 'E_ACCESS_DENIED'], @@ -197,6 +234,8 @@ public function test_wstoken_access_check(): void { /** * Test that invalid jobs return no status * + * @covers \quiz_archiver\external\get_backup_status::execute + * * @return void * @throws \coding_exception * @throws \dml_exception diff --git a/tests/external/process_uploaded_artifact_test.php b/tests/external/process_uploaded_artifact_test.php new file mode 100644 index 0000000..fa79de3 --- /dev/null +++ b/tests/external/process_uploaded_artifact_test.php @@ -0,0 +1,442 @@ +. + +/** + * Tests for the process_uploaded_artifact external service + * + * @package quiz_archiver + * @copyright 2024 Niels Gandraß + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace quiz_archiver\external; + + +use quiz_archiver\ArchiveJob; +use quiz_archiver\FileManager; + +/** + * Tests for the process_uploaded_artifact external service + */ +final class process_uploaded_artifact_test extends \advanced_testcase { + + /** + * Returns the data generator for the quiz_archiver plugin + * + * @return \quiz_archiver_generator The data generator for the quiz_archiver plugin + */ + // @codingStandardsIgnoreLine + public static function getDataGenerator(): \quiz_archiver_generator { + return parent::getDataGenerator()->get_plugin_generator('quiz_archiver'); + } + + /** + * Generates a set of valid parameters + * + * @param string $jobid Job ID + * @param int $cmid Course module ID + * @param int $userid User ID + * @return array Valid request parameters + */ + protected function generate_valid_request(string $jobid, int $cmid, int $userid): array { + return [ + 'jobid' => $jobid, + 'artifact_component' => FileManager::COMPONENT_NAME, + 'artifact_contextid' => \context_module::instance($cmid)->id, + 'artifact_userid' => $userid, + 'artifact_filearea' => FileManager::TEMP_FILEAREA_NAME, + 'artifact_filename' => 'artifact.tar.gz', + 'artifact_filepath' => '/', + 'artifact_itemid' => 1, + 'artifact_sha256sum' => '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + ]; + } + + /** + * Tests that the parameter spec is specified correctly and produces no exception. + * + * @covers \quiz_archiver\external\process_uploaded_artifact::execute_parameters + * + * @return void + */ + public function test_assure_execute_parameter_spec(): void { + $this->resetAfterTest(); + $this->assertInstanceOf( + \core_external\external_function_parameters::class, + process_uploaded_artifact::execute_parameters(), + 'The execute_parameters() method should return an external_function_parameters.' + ); + } + + /** + * Tests that the return parameters are specified correctly and produce no exception. + * + * @covers \quiz_archiver\external\process_uploaded_artifact::execute_returns + * + * @return void + */ + public function test_assure_return_parameter_spec(): void { + $this->assertInstanceOf( + \core_external\external_description::class, + process_uploaded_artifact::execute_returns(), + 'The execute_returns() method should return an external_description.' + ); + } + + /** + * Test that users without the required capabilities are rejected + * + * @covers \quiz_archiver\external\process_uploaded_artifact::execute + * + * @return void + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_capability_requirement(): void { + // Check that a user without the required capability is rejected. + $this->expectException(\required_capability_exception::class); + $this->expectExceptionMessageMatches('/.*mod\/quiz_archiver:use_webservice.*/'); + + // Create job. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $job = ArchiveJob::create( + '10000000-1234-5678-abcd-ef4242424242', + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + null, + 'TEST-WS-TOKEN', + [], + [] + ); + + // Execute test call. + $_GET['wstoken'] = 'TEST-WS-TOKEN'; + $r = $this->generate_valid_request($job->get_jobid(), $mocks->quiz->cmid, $mocks->user->id); + process_uploaded_artifact::execute( + $r['jobid'], + $r['artifact_component'], + $r['artifact_contextid'], + $r['artifact_userid'], + $r['artifact_filearea'], + $r['artifact_filename'], + $r['artifact_filepath'], + $r['artifact_itemid'], + $r['artifact_sha256sum'] + ); + } + + /** + * Tests that only web service tokens with write access to a job can trigger + * artifact upload processing + * + * @covers \quiz_archiver\external\process_uploaded_artifact::execute + * + * @return void + * @throws \coding_exception + * @throws \dml_exception + * @throws \invalid_parameter_exception + * @throws \moodle_exception + * @throws \required_capability_exception + */ + public function test_wstoken_write_access_check(): void { + // Create job. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $job = ArchiveJob::create( + '11000000-1234-5678-abcd-ef4242424242', + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + null, + 'TEST-WS-TOKEN', + [], + [] + ); + + // Execute test call. + $_GET['wstoken'] = 'INVALID-WS-TOKEN'; + $r = $this->generate_valid_request($job->get_jobid(), $mocks->quiz->cmid, $mocks->user->id); + $res = process_uploaded_artifact::execute( + $r['jobid'], + $r['artifact_component'], + $r['artifact_contextid'], + $r['artifact_userid'], + $r['artifact_filearea'], + $r['artifact_filename'], + $r['artifact_filepath'], + $r['artifact_itemid'], + $r['artifact_sha256sum'] + ); + + // Ensure that the access was denied. + $this->assertSame(['status' => 'E_ACCESS_DENIED'], $res, 'Websertice token without access rights was falsely accepted'); + } + + /** + * Verifies webservice parameter validation + * + * @dataProvider parameter_data_provider + * @covers \quiz_archiver\external\process_uploaded_artifact::execute + * @covers \quiz_archiver\external\process_uploaded_artifact::validate_parameters + * + * @param string|null $jobid Job ID + * @param string|null $artifactcomponent Component name + * @param int|null $artifactcontextid Context ID + * @param int|null $artifactuserid User ID + * @param string|null $artifactfilearea File area name + * @param string|null $artifactfilename File name + * @param string|null $artifactfilepath File path + * @param int|null $artifactitemid Item ID + * @param string|null $artifactsha256sum SHA256 checksum + * @param bool $shouldfail Whether a failure is expected + * @return void + * @throws \coding_exception + * @throws \dml_exception + * @throws \invalid_parameter_exception + * @throws \required_capability_exception + */ + public function test_parameter_validation( + ?string $jobid, + ?string $artifactcomponent, + ?int $artifactcontextid, + ?int $artifactuserid, + ?string $artifactfilearea, + ?string $artifactfilename, + ?string $artifactfilepath, + ?int $artifactitemid, + ?string $artifactsha256sum, + bool $shouldfail + ): void { + // Create mock quiz. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $base = $this->generate_valid_request('xxx', $mocks->quiz->cmid, $mocks->user->id); + + if ($shouldfail) { + $this->expectException(\invalid_parameter_exception::class); + } + + process_uploaded_artifact::execute( + $jobid === null ? $base['jobid'] : $jobid, + $artifactcomponent === null ? $base['artifact_component'] : $artifactcomponent, + $artifactcontextid === null ? $base['artifact_contextid'] : $artifactcontextid, + $artifactuserid === null ? $base['artifact_userid'] : $artifactuserid, + $artifactfilearea === null ? $base['artifact_filearea'] : $artifactfilearea, + $artifactfilename === null ? $base['artifact_filename'] : $artifactfilename, + $artifactfilepath === null ? $base['artifact_filepath'] : $artifactfilepath, + $artifactitemid === null ? $base['artifact_itemid'] : $artifactitemid, + $artifactsha256sum === null ? $base['artifact_sha256sum'] : $artifactsha256sum + ); + } + + /** + * Data provider for test_parameter_validation + * + * @return array[] Test data + */ + public static function parameter_data_provider(): array { + // Create base data (no modification). + $base = [ + "jobid" => null, + "artifact_component" => null, + "artifact_contextid" => null, + "artifact_userid" => null, + "artifact_filearea" => null, + "artifact_filename" => null, + "artifact_filepath" => null, + "artifact_itemid" => null, + "artifact_sha256sum" => null, + ]; + + // Define test datasets. + return [ + 'Valid' => array_merge($base, [ + 'shouldfail' => false, + ]), + 'Invalid jobid' => array_merge($base, [ + 'jobid' => 'Foo', + 'shouldfail' => true, + ]), + 'Invalid artifact_component' => array_merge($base, [ + 'artifact_component' => 'Foo', + 'shouldfail' => true, + ]), + 'Invalid artifact_filearea' => array_merge($base, [ + 'artifact_filearea' => 'Foo', + 'shouldfail' => true, + ]), + 'Invalid artifact_filename' => array_merge($base, [ + 'artifact_filename' => 'Foo', + 'shouldfail' => true, + ]), + 'Invalid artifact_filepath' => array_merge($base, [ + 'artifact_filepath' => 'Foo', + 'shouldfail' => true, + ]), + 'Invalid artifact_sha256sum' => array_merge($base, [ + 'artifact_sha256sum' => 'Foo', + 'shouldfail' => true, + ]), + ]; + } + + /** + * Test that completed jobs reject further artifact uploads + * + * @covers \quiz_archiver\external\process_uploaded_artifact::execute + * + * @return void + * @throws \coding_exception + * @throws \dml_exception + * @throws \invalid_parameter_exception + * @throws \moodle_exception + * @throws \required_capability_exception + */ + public function test_rejection_of_artifacts_for_complete_jobs(): void { + // Create job. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $job = ArchiveJob::create( + '20000000-1234-5678-abcd-ef4242424242', + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + null, + 'TEST-WS-TOKEN', + [], + [], + ArchiveJob::STATUS_FINISHED + ); + + // Execute test call. + $_GET['wstoken'] = 'TEST-WS-TOKEN'; + $r = $this->generate_valid_request($job->get_jobid(), $mocks->quiz->cmid, $mocks->user->id); + $this->assertSame(['status' => 'E_NO_ARTIFACT_UPLOAD_EXPECTED'], process_uploaded_artifact::execute( + $r['jobid'], + $r['artifact_component'], + $r['artifact_contextid'], + $r['artifact_userid'], + $r['artifact_filearea'], + $r['artifact_filename'], + $r['artifact_filepath'], + $r['artifact_itemid'], + $r['artifact_sha256sum'] + )); + } + + /** + * Test that missing files are reported correctly + * + * @covers \quiz_archiver\external\process_uploaded_artifact::execute + * + * @return void + * @throws \coding_exception + * @throws \dml_exception + * @throws \invalid_parameter_exception + * @throws \moodle_exception + * @throws \required_capability_exception + */ + public function test_invalid_file_metadata(): void { + // Create job. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $job = ArchiveJob::create( + '30000000-1234-5678-abcd-ef4242424242', + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + null, + 'TEST-WS-TOKEN', + [], + [] + ); + + // Gain access. + $_GET['wstoken'] = 'TEST-WS-TOKEN'; + $this->setAdminUser(); + + // Execute test call. + $r = $this->generate_valid_request($job->get_jobid(), $mocks->quiz->cmid, $mocks->user->id); + $this->assertSame(['status' => 'E_UPLOADED_ARTIFACT_NOT_FOUND'], process_uploaded_artifact::execute( + $r['jobid'], + $r['artifact_component'], + $r['artifact_contextid'], + $r['artifact_userid'], + $r['artifact_filearea'], + $r['artifact_filename'], + $r['artifact_filepath'], + $r['artifact_itemid'], + $r['artifact_sha256sum'] + )); + } + + /** + * Tests rejection of artifacts with mismatching checksums + * + * @covers \quiz_archiver\external\process_uploaded_artifact::execute + * + * @return void + * @throws \coding_exception + * @throws \dml_exception + * @throws \file_exception + * @throws \invalid_parameter_exception + * @throws \moodle_exception + * @throws \required_capability_exception + * @throws \stored_file_creation_exception + */ + public function test_rejection_of_artifacts_with_checksum_mismatch(): void { + // Create job and draft artifact. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $job = ArchiveJob::create( + '40000000-1234-5678-abcd-ef4242424242', + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + null, + 'TEST-WS-TOKEN', + [], + [] + ); + $artifact = $this->getDataGenerator()->create_draft_file('testartifact.tar.gz'); + + // Gain access. + $_GET['wstoken'] = 'TEST-WS-TOKEN'; + $this->setAdminUser(); + + // Execute test call. + $r = $this->generate_valid_request($job->get_jobid(), $mocks->quiz->cmid, $mocks->user->id); + $this->assertSame(['status' => 'E_ARTIFACT_CHECKSUM_INVALID'], process_uploaded_artifact::execute( + $r['jobid'], + $artifact->get_component(), + $artifact->get_contextid(), + (int) $artifact->get_userid(), // Int cast is required since Moodle likes to return strings here... + $artifact->get_filearea(), + $artifact->get_filename(), + $artifact->get_filepath(), + $artifact->get_itemid(), + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' + )); + } + +} diff --git a/tests/external/update_job_status_test.php b/tests/external/update_job_status_test.php new file mode 100644 index 0000000..c774e78 --- /dev/null +++ b/tests/external/update_job_status_test.php @@ -0,0 +1,468 @@ +. + +/** + * Tests for the update_job_status external service + * + * @package quiz_archiver + * @copyright 2024 Niels Gandraß + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace quiz_archiver\external; + + +use quiz_archiver\ArchiveJob; + +/** + * Tests for the update_job_status external service + */ +final class update_job_status_test extends \advanced_testcase { + + /** + * Returns the data generator for the quiz_archiver plugin + * + * @return \quiz_archiver_generator The data generator for the quiz_archiver plugin + */ + // @codingStandardsIgnoreLine + public static function getDataGenerator(): \quiz_archiver_generator { + return parent::getDataGenerator()->get_plugin_generator('quiz_archiver'); + } + + /** + * Tests that the parameter spec is specified correctly and produces no exception. + * + * @covers \quiz_archiver\external\update_job_status::execute_parameters + * + * @return void + */ + public function test_assure_execute_parameter_spec(): void { + $this->resetAfterTest(); + $this->assertInstanceOf( + \core_external\external_function_parameters::class, + update_job_status::execute_parameters(), + 'The execute_parameters() method should return an external_function_parameters.' + ); + } + + /** + * Tests that the return parameters are specified correctly and produce no exception. + * + * @covers \quiz_archiver\external\update_job_status::execute_returns + * + * @return void + */ + public function test_assure_return_parameter_spec(): void { + $this->assertInstanceOf( + \core_external\external_description::class, + update_job_status::execute_returns(), + 'The execute_returns() method should return an external_description.' + ); + } + + /** + * Test that users without the required capabilities are rejected + * + * @covers \quiz_archiver\external\update_job_status::execute + * + * @return void + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_capability_requirement(): void { + // Create mock quiz and job. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $job = ArchiveJob::create( + '00000000-1234-5678-abcd-ef4242424242', + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + null, + 'TEST-WS-TOKEN', + [], + [], + ); + $_GET['wstoken'] = 'TEST-WS-TOKEN'; + + // Check that a user without the required capability is rejected. + $this->expectException(\required_capability_exception::class); + $this->expectExceptionMessageMatches('/.*mod\/quiz_archiver:use_webservice.*/'); + update_job_status::execute($job->get_jobid(), ArchiveJob::STATUS_UNINITIALIZED); + } + + /** + * Tests that webservice tokens are validated against the requested job + * + * @covers \quiz_archiver\external\update_job_status::execute + * + * @return void + * @throws \coding_exception + * @throws \dml_exception + * @throws \invalid_parameter_exception + * @throws \moodle_exception + * @throws \required_capability_exception + */ + public function test_wstoken_validation(): void { + // Gain access to webservice. + $this->setAdminUser(); + + // Create mock quiz and job. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $job = ArchiveJob::create( + '00000000-1234-5678-abcd-ef4242424242', + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + null, + 'TEST-WS-TOKEN-VALID', + [], + [], + ); + + // Check that a valid token is accepted. + $_GET['wstoken'] = 'TEST-WS-TOKEN-VALID'; + $this->assertSame( + ['status' => 'OK'], + update_job_status::execute($job->get_jobid(), ArchiveJob::STATUS_UNINITIALIZED), + 'Valid token was rejected' + ); + + // Check that an invalid token is rejected. + $_GET['wstoken'] = 'TEST-WS-TOKEN-INVALID'; + $this->assertSame( + ['status' => 'E_ACCESS_DENIED'], + update_job_status::execute($job->get_jobid(), ArchiveJob::STATUS_UNINITIALIZED), + 'Invalid token was accepted' + ); + } + + /** + * Verifies webservice parameter validation + * + * @dataProvider parameter_data_provider + * @covers \quiz_archiver\external\update_job_status::execute + * @covers \quiz_archiver\external\update_job_status::validate_parameters + * + * @param string $jobid Raw jobid parameter + * @param string $status Raw status parameter + * @param bool $shouldfail Whether a failure is expected + * @return void + * @throws \coding_exception + * @throws \invalid_parameter_exception + * @throws \required_capability_exception + */ + public function test_parameter_validation(string $jobid, string $status, bool $shouldfail): void { + if ($shouldfail) { + $this->expectException(\invalid_parameter_exception::class); + } + + update_job_status::execute($jobid, $status); + } + + /** + * Data provider for test_parameter_validation + * + * @return array[] Test data + */ + public static function parameter_data_provider(): array { + return [ + 'Valid' => [ + 'jobid' => '00000000-1234-5678-abcd-ef4242424242', + 'status' => ArchiveJob::STATUS_UNINITIALIZED, + 'shouldfail' => false, + ], + 'Invalid jobid' => [ + 'jobid' => 'Foo', + 'status' => ArchiveJob::STATUS_UNINITIALIZED, + 'shouldfail' => true, + ], + 'Invalid status' => [ + 'jobid' => '00000000-1234-5678-abcd-ef4242424242', + 'status' => 'Bar', + 'shouldfail' => true, + ], + 'Invalid jobid and status' => [ + 'jobid' => 'Foo', + 'status' => 'Bar', + 'shouldfail' => true, + ], + ]; + } + + /** + * Test updating a valid job + * + * @dataProvider job_status_data_provider + * @covers \quiz_archiver\external\update_job_status::execute + * + * @param string $originstatus Status to transition from + * @param string $targetstatus Status to transition to + * @param array $expected Expected result + * @return void + * @throws \coding_exception + * @throws \dml_exception + * @throws \invalid_parameter_exception + * @throws \moodle_exception + * @throws \required_capability_exception + */ + public function test_update_job_status(string $originstatus, string $targetstatus, array $expected): void { + // Gain privileges. + $this->setAdminUser(); + $_GET['wstoken'] = 'TEST-WS-TOKEN'; + + // Create mock quiz and job. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $job = ArchiveJob::create( + '00000000-1234-5678-abcd-ef4242424242', + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + null, + 'TEST-WS-TOKEN', + [], + [], + $originstatus + ); + + // Ensure job is in the expected state. + $this->assertSame($originstatus, $job->get_status()); + + // Execute the external function and check the result. + $result = update_job_status::execute( + $job->get_jobid(), + $targetstatus + ); + $this->assertSame($expected, $result, 'Invalid webservice answer'); + } + + /** + * Data provider for test_update_job_status + * + * @return array[] Test data + */ + public static function job_status_data_provider(): array { + return [ + 'Status: UNKNOWN -> UNINITIALIZED' => [ + 'originstatus' => ArchiveJob::STATUS_UNKNOWN, + 'targetstatus' => ArchiveJob::STATUS_UNINITIALIZED, + 'expected' => ['status' => 'OK'], + ], + 'Status: UNINITIALIZED -> AWAITING_PROCESSING' => [ + 'originstatus' => ArchiveJob::STATUS_UNINITIALIZED, + 'targetstatus' => ArchiveJob::STATUS_AWAITING_PROCESSING, + 'expected' => ['status' => 'OK'], + ], + 'Status: UNINITIALIZED -> FINISHED' => [ + 'originstatus' => ArchiveJob::STATUS_UNINITIALIZED, + 'targetstatus' => ArchiveJob::STATUS_FINISHED, + 'expected' => ['status' => 'OK'], + ], + 'Status: AWAITING_PROCESSING -> RUNNING' => [ + 'originstatus' => ArchiveJob::STATUS_AWAITING_PROCESSING, + 'targetstatus' => ArchiveJob::STATUS_RUNNING, + 'expected' => ['status' => 'OK'], + ], + 'Status: RUNNING -> FINISHED' => [ + 'originstatus' => ArchiveJob::STATUS_RUNNING, + 'targetstatus' => ArchiveJob::STATUS_FINISHED, + 'expected' => ['status' => 'OK'], + ], + 'Status: RUNNING -> FAILED' => [ + 'originstatus' => ArchiveJob::STATUS_RUNNING, + 'targetstatus' => ArchiveJob::STATUS_FAILED, + 'expected' => ['status' => 'OK'], + ], + 'Status: RUNNING -> TIMEOUT' => [ + 'originstatus' => ArchiveJob::STATUS_RUNNING, + 'targetstatus' => ArchiveJob::STATUS_TIMEOUT, + 'expected' => ['status' => 'OK'], + ], + 'Status: FINISHED -> DELETED' => [ + 'originstatus' => ArchiveJob::STATUS_FINISHED, + 'targetstatus' => ArchiveJob::STATUS_DELETED, + 'expected' => ['status' => 'E_JOB_ALREADY_COMPLETED'], + ], + 'Status: FINISHED -> RUNNING' => [ + 'originstatus' => ArchiveJob::STATUS_FINISHED, + 'targetstatus' => ArchiveJob::STATUS_RUNNING, + 'expected' => ['status' => 'E_JOB_ALREADY_COMPLETED'], + ], + 'Status: FINISHED -> FAILED' => [ + 'originstatus' => ArchiveJob::STATUS_FINISHED, + 'targetstatus' => ArchiveJob::STATUS_FAILED, + 'expected' => ['status' => 'E_JOB_ALREADY_COMPLETED'], + ], + 'Status: FINISHED -> TIMEOUT' => [ + 'originstatus' => ArchiveJob::STATUS_FINISHED, + 'targetstatus' => ArchiveJob::STATUS_TIMEOUT, + 'expected' => ['status' => 'E_JOB_ALREADY_COMPLETED'], + ], + 'Status: FINISHED -> UNINITIALIZED' => [ + 'originstatus' => ArchiveJob::STATUS_FINISHED, + 'targetstatus' => ArchiveJob::STATUS_UNINITIALIZED, + 'expected' => ['status' => 'E_JOB_ALREADY_COMPLETED'], + ], + 'Status: FAILED -> DELETED' => [ + 'originstatus' => ArchiveJob::STATUS_FAILED, + 'targetstatus' => ArchiveJob::STATUS_DELETED, + 'expected' => ['status' => 'E_JOB_ALREADY_COMPLETED'], + ], + ]; + } + + /** + * Verifies that statusextras are decoded and stored correctly and that + * invalid JSON is properly rejected + * + * @dataProvider statusextras_data_provider + * @covers \quiz_archiver\external\update_job_status::execute + * + * @param string $jobid + * @param string $status + * @param string|null $statusextras + * @param bool $shouldfail + * @return void + * @throws \coding_exception + * @throws \invalid_parameter_exception + * @throws \required_capability_exception + */ + public function test_statusextras(string $jobid, string $status, ?string $statusextras, bool $shouldfail): void { + // Gain privileges. + $this->setAdminUser(); + $_GET['wstoken'] = 'TEST-WS-TOKEN'; + + // Create mock quiz and job. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $job = ArchiveJob::create( + '00000000-1234-5678-abcd-ef4242424242', + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + null, + 'TEST-WS-TOKEN', + [], + [], + ArchiveJob::STATUS_UNINITIALIZED + ); + + // Perform status update. + $result = update_job_status::execute($jobid, $status, $statusextras); + + if ($shouldfail) { + $this->assertSame( + ['status' => 'E_INVALID_STATUSEXTRAS_JSON'], + $result, + 'Invalid statusextras was accepted' + ); + } else { + $this->assertSame( + ['status' => 'OK'], + $result, + 'Valid statusextras was rejected' + ); + $this->assertSame( + $status, + $job->get_status(), + 'Job status was not updated correctly' + ); + if ($statusextras) { + $this->assertSame( + json_decode($statusextras, true), + $job->get_statusextras(), + 'Populated statusextras were not updated correctly' + ); + } else { + $this->assertNull( + $job->get_statusextras(), + 'Empty statusextras were not updated correctly' + ); + } + } + } + + /** + * Data provider for test_statusextras + * + * @return array[] Test data + */ + public static function statusextras_data_provider(): array { + return [ + 'No JSON' => [ + 'jobid' => '00000000-1234-5678-abcd-ef4242424242', + 'status' => ArchiveJob::STATUS_FINALIZING, + 'statusextras' => null, + 'shouldfail' => false, + ], + 'Valid JSON 1' => [ + 'jobid' => '00000000-1234-5678-abcd-ef4242424242', + 'status' => ArchiveJob::STATUS_RUNNING, + 'statusextras' => '{"foo": "bar"}', + 'shouldfail' => false, + ], + 'Valid JSON 2' => [ + 'jobid' => '00000000-1234-5678-abcd-ef4242424242', + 'status' => ArchiveJob::STATUS_RUNNING, + 'statusextras' => '{"foo": "bar", "baz": []}', + 'shouldfail' => false, + ], + 'Invalid JSON 1' => [ + 'jobid' => '00000000-1234-5678-abcd-ef4242424242', + 'status' => ArchiveJob::STATUS_UNKNOWN, + 'statusextras' => '{"foo": "bar"', + 'shouldfail' => true, + ], + 'Invalid JSON 2' => [ + 'jobid' => '00000000-1234-5678-abcd-ef4242424242', + 'status' => ArchiveJob::STATUS_UNKNOWN, + 'statusextras' => '{"foo": "bar",}', + 'shouldfail' => true, + ], + 'Invalid JSON 3' => [ + 'jobid' => '00000000-1234-5678-abcd-ef4242424242', + 'status' => ArchiveJob::STATUS_UNKNOWN, + 'statusextras' => '{"foo": "bar", "baz":}', + 'shouldfail' => true, + ], + 'Invalid JSON 4' => [ + 'jobid' => '00000000-1234-5678-abcd-ef4242424242', + 'status' => ArchiveJob::STATUS_UNKNOWN, + 'statusextras' => '{"foo": "bar", "baz": []', + 'shouldfail' => true, + ], + 'Invalid JSON 5' => [ + 'jobid' => '00000000-1234-5678-abcd-ef4242424242', + 'status' => ArchiveJob::STATUS_UNKNOWN, + 'statusextras' => '"foo": "bar", "baz": {}', + 'shouldfail' => true, + ], + 'Invalid JSON 6' => [ + 'jobid' => '00000000-1234-5678-abcd-ef4242424242', + 'status' => ArchiveJob::STATUS_UNKNOWN, + 'statusextras' => '{"foo":', + 'shouldfail' => true, + ], + ]; + } + +} diff --git a/tests/filemanager_test.php b/tests/filemanager_test.php new file mode 100644 index 0000000..70b6b19 --- /dev/null +++ b/tests/filemanager_test.php @@ -0,0 +1,692 @@ +. + +/** + * Tests for the FileManager class + * + * @package quiz_archiver + * @copyright 2024 Niels Gandraß + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace quiz_archiver; + +/** + * Tests for the FileManager class + */ +final class filemanager_test extends \advanced_testcase { + + /** + * Returns the data generator for the quiz_archiver plugin + * + * @return \quiz_archiver_generator The data generator for the quiz_archiver plugin + */ + // @codingStandardsIgnoreLine + public static function getDataGenerator(): \quiz_archiver_generator { + return parent::getDataGenerator()->get_plugin_generator('quiz_archiver'); + } + + /** + * Tests the generation of file paths based on context data + * + * @covers \quiz_archiver\FileManager::get_file_path + * + * @dataProvider file_path_generator_data_provider + * + * @param int $courseid + * @param int $cmid + * @param int $quizid + * @param string $expectedpath + * @return void + */ + public function test_file_path_generator(int $courseid, int $cmid, int $quizid, string $expectedpath): void { + $this->assertEquals($expectedpath, FileManager::get_file_path($courseid, $cmid, $quizid)); + } + + /** + * Data provider for test_file_path_generator + * + * @return array Test data for test_file_path_generator + */ + public static function file_path_generator_data_provider(): array { + return [ + 'Full valid path' => [ + 1, + 2, + 3, + '/1/2/3/', + ], + 'Empty path' => [ + 0, + 0, + 0, + '/', + ], + 'Only course' => [ + 1, + 0, + 0, + '/1/', + ], + 'Only course and cm' => [ + 1, + 2, + 0, + '/1/2/', + ], + 'Only course and quiz' => [ + 1, + 0, + 3, + '/1/', + ], + 'Only cm' => [ + 0, + 2, + 0, + '/', + ], + 'Only cm and quiz' => [ + 0, + 2, + 3, + '/', + ], + 'Only quiz' => [ + 0, + 0, + 3, + '/', + ], + ]; + } + + /** + * Test artifact storing and retrieval + * + * @covers \quiz_archiver\FileManager::__construct + * @covers \quiz_archiver\FileManager::store_uploaded_artifact + * @covers \quiz_archiver\FileManager::get_stored_artifacts + * @covers \quiz_archiver\FileManager::get_own_file_path + * + * @return void + * @throws \coding_exception + * @throws \file_exception + * @throws \stored_file_creation_exception + */ + public function test_artifact_storing(): void { + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $fm = new FileManager($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id); + $draftfile = $this->getDataGenerator()->create_draft_file('testfile.tar.gz'); + $draftfilehash = $draftfile->get_contenthash(); + + // Store draftfile as artifact. + $storedfile = $fm->store_uploaded_artifact($draftfile, 42); + $this->assertInstanceOf(\stored_file::class, $storedfile, 'Invalid storage handle returned'); + $this->assertEquals($draftfilehash, $storedfile->get_contenthash(), 'Stored file hash does not match draft file hash'); + $this->assertEmpty(get_file_storage()->get_file_by_id($draftfile->get_id()), 'Draft file was deleted'); + + // Retrieve artifact. + $storedfiles = $fm->get_stored_artifacts(); + $this->assertEquals($storedfile, array_shift($storedfiles), 'Stored file handle does not match retrieved file handle'); + } + + /** + * Test that only uploaded draftfiles are stored and others are rejected + * + * @covers \quiz_archiver\FileManager::__construct + * @covers \quiz_archiver\FileManager::store_uploaded_artifact + * + * @return void + * @throws \file_exception + * @throws \stored_file_creation_exception + */ + public function test_artifact_storing_invalid_file(): void { + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $fm = new FileManager($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id); + $invalidfile = $this->getDataGenerator()->create_draft_file('invalidfile.tar.gz', 'invalidarea'); + + $this->expectException(\file_exception::class); + $this->expectExceptionMessageMatches('/draftfile/'); + $fm->store_uploaded_artifact($invalidfile, 1337); + } + + /** + * Test retrieval of draft files from the file storage + * + * @covers \quiz_archiver\FileManager::get_draft_file + * + * @return void + * @throws \file_exception + * @throws \stored_file_creation_exception + */ + public function test_get_draft_file(): void { + // Prepare mocks. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $fm = new FileManager($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id); + $draftfile = $this->getDataGenerator()->create_draft_file('testfile.tar.gz'); + + // Retrieve valid draftfile. + $this->assertEquals( + $draftfile->get_id(), + $fm->get_draft_file( + $draftfile->get_contextid(), + $draftfile->get_itemid(), + $draftfile->get_filepath(), + $draftfile->get_filename() + )->get_id(), + 'Draft file was not returned correctly' + ); + + // Retrieve invalid draftfile. + $this->assertNull( + $fm->get_draft_file( + $draftfile->get_contextid(), + $draftfile->get_itemid(), + $draftfile->get_filepath(), + 'invalidfile.tar.gz' + ), + 'A draft file that should not exist was returned' + ); + } + + /** + * Tests the hash generation for a valid stored_file + * + * @covers \quiz_archiver\FileManager::hash_file + * + * @return void + * @throws \file_exception + * @throws \stored_file_creation_exception + */ + public function test_hash_valid_file(): void { + $this->resetAfterTest(); + $file = $this->getDataGenerator()->create_draft_file('testartifact.tar.gz'); + $defaulthash = FileManager::hash_file($file); + $this->assertNotEmpty($defaulthash, 'Default hash is empty'); + $this->assertSame(64, strlen($defaulthash), 'Default hash length is not 64 bytes, as expected from SHA256'); + + $sha256hash = FileManager::hash_file($file, 'sha256'); + $this->assertEquals($defaulthash, $sha256hash, 'Explicitly as SHA256 selected hash does not match default hash'); + } + + /** + * Tests hash generation using an invalid hash algorithm + * + * @covers \quiz_archiver\FileManager::hash_file + * + * @return void + * @throws \file_exception + * @throws \stored_file_creation_exception + */ + public function test_hash_file_invalid_algorithm(): void { + $this->resetAfterTest(); + $file = $this->getDataGenerator()->create_draft_file('testartifact.tar.gz'); + $this->assertNull(FileManager::hash_file($file, 'invalid-algorithm'), 'Invalid algorithm did not return null'); + } + + /** + * Tests sending a TSP query as a virtual file + * + * @runInSeparateProcess + * @covers \quiz_archiver\FileManager::send_virtual_file + * @covers \quiz_archiver\FileManager::send_virtual_file_tsp + * @covers \quiz_archiver\FileManager::filearea_is_virtual + * + * @return void + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_send_virtual_file_tsp_query(): void { + global $CFG, $DB; + + if ($CFG->branch < 404) { + // @codingStandardsIgnoreLine + $this->markTestSkipped('This test requires Moodle 4.4 or higher. PHPUnit process isolation does not work properly with older versions.'); + } + + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $job = ArchiveJob::create( + '00000000000000000000000001', + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + 2, + 0, + 'wstoken', + [], + [], + ArchiveJob::STATUS_FINISHED + ); + + // Generate mock TSP data. + $DB->insert_record(TSPManager::TSP_TABLE_NAME, [ + 'jobid' => $job->get_id(), + 'timecreated' => time(), + 'server' => 'localhost', + 'timestampquery' => 'tspquery1', + 'timestampreply' => 'tspreply1', + ]); + + // Try to send file. + $fm = new FileManager($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id); + $fm->send_virtual_file( + FileManager::TSP_DATA_FILEAREA_NAME, + "/{$mocks->course->id}/{$mocks->quiz->cmid}/{$mocks->quiz->id}/{$job->get_id()}/".FileManager::TSP_DATA_QUERY_FILENAME + ); + } + + /** + * Tests sending a TSP reply as a virtual file + * + * @runInSeparateProcess + * @covers \quiz_archiver\FileManager::send_virtual_file + * @covers \quiz_archiver\FileManager::send_virtual_file_tsp + * @covers \quiz_archiver\FileManager::filearea_is_virtual + * + * @return void + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_send_virtual_file_tsp_reply(): void { + global $CFG, $DB; + + if ($CFG->branch < 404) { + // @codingStandardsIgnoreLine + $this->markTestSkipped('This test requires Moodle 4.4 or higher. PHPUnit process isolation does not work properly with older versions.'); + } + + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $job = ArchiveJob::create( + '00000000000000000000000002', + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + 2, + 0, + 'wstoken', + [], + [], + ArchiveJob::STATUS_FINISHED + ); + + // Generate mock TSP data. + $DB->insert_record(TSPManager::TSP_TABLE_NAME, [ + 'jobid' => $job->get_id(), + 'timecreated' => time(), + 'server' => 'localhost', + 'timestampquery' => 'tspquery2', + 'timestampreply' => 'tspreply2', + ]); + + // Try to send file. + $fm = new FileManager($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id); + $fm->send_virtual_file( + FileManager::TSP_DATA_FILEAREA_NAME, + "/{$mocks->course->id}/{$mocks->quiz->cmid}/{$mocks->quiz->id}/{$job->get_id()}/".FileManager::TSP_DATA_REPLY_FILENAME + ); + } + + /** + * Tests sending a virtual TSP file for a relativepath that does not match + * the information of the respective job. + * + * @covers \quiz_archiver\FileManager::send_virtual_file + * @covers \quiz_archiver\FileManager::send_virtual_file_tsp + * @covers \quiz_archiver\FileManager::filearea_is_virtual + * + * @return void + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_send_virtual_files_tsp_invalid_job(): void { + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $job = ArchiveJob::create( + '00000000000000000000000003', + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + 2, + 0, + 'wstoken', + [], + [], + ArchiveJob::STATUS_UNKNOWN + ); + $fm = new FileManager($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id); + + // Test invalid job. + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/resource id/'); + $fm->send_virtual_file( + FileManager::TSP_DATA_FILEAREA_NAME, + "/{$mocks->course->id}/{$mocks->quiz->cmid}/0/{$job->get_id()}/".FileManager::TSP_DATA_REPLY_FILENAME + ); + } + + /** + * Tests sending a virtual TSP file for a job that has no TSP data. + * + * @covers \quiz_archiver\FileManager::send_virtual_file + * @covers \quiz_archiver\FileManager::send_virtual_file_tsp + * @covers \quiz_archiver\FileManager::filearea_is_virtual + * + * @return void + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_send_virtual_files_tsp_unsigned_job(): void { + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $job = ArchiveJob::create( + '00000000000000000000000004', + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + 2, + 0, + 'wstoken', + [], + [], + ArchiveJob::STATUS_FINISHED + ); + $fm = new FileManager($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id); + + // Test unsigned job. + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/No TSP data found/'); + $fm->send_virtual_file( + FileManager::TSP_DATA_FILEAREA_NAME, + "/{$mocks->course->id}/{$mocks->quiz->cmid}/{$mocks->quiz->id}/{$job->get_id()}/".FileManager::TSP_DATA_REPLY_FILENAME + ); + } + + /** + * Tests sending virtual file from invalid filearea. + * + * @covers \quiz_archiver\FileManager::send_virtual_file + * @covers \quiz_archiver\FileManager::send_virtual_file_tsp + * @covers \quiz_archiver\FileManager::filearea_is_virtual + * + * @return void + * @throws \dml_exception + */ + public function test_send_virtual_files_invalid_filearea(): void { + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $fm = new FileManager($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id); + + // Test invalid filearea. + $this->expectException(\InvalidArgumentException::class); + $fm->send_virtual_file('invalid', '/invalid'); + } + + /** + * Tests sending virtual file from invalid path. + * + * @covers \quiz_archiver\FileManager::send_virtual_file + * @covers \quiz_archiver\FileManager::send_virtual_file_tsp + * @covers \quiz_archiver\FileManager::filearea_is_virtual + * + * @return void + * @throws \dml_exception + */ + public function test_send_virtual_files_invalid_path(): void { + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $fm = new FileManager($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id); + + // Test invalid path. + $this->expectException(\InvalidArgumentException::class); + $fm->send_virtual_file(FileManager::TSP_DATA_FILEAREA_NAME, '../../42/secrets'); + } + + /** + * Tests sending virtual file with invalid jobid. + * + * @covers \quiz_archiver\FileManager::send_virtual_file + * @covers \quiz_archiver\FileManager::send_virtual_file_tsp + * @covers \quiz_archiver\FileManager::filearea_is_virtual + * + * @return void + * @throws \dml_exception + */ + public function test_send_virtual_files_invalid_jobid(): void { + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $fm = new FileManager($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id); + + // Test invalid job-id. + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/jobid/'); + $fm->send_virtual_file( + FileManager::TSP_DATA_FILEAREA_NAME, + '/0/0/0/invalidjobid/'.FileManager::TSP_DATA_REPLY_FILENAME + ); + } + + /** + * Tests sending virtual file for non-existing job. + * + * @covers \quiz_archiver\FileManager::send_virtual_file + * @covers \quiz_archiver\FileManager::send_virtual_file_tsp + * @covers \quiz_archiver\FileManager::filearea_is_virtual + * + * @return void + * @throws \dml_exception + */ + public function test_send_virtual_files_missing_job(): void { + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $fm = new FileManager($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id); + + // Test missing job. + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/not found/'); + $fm->send_virtual_file( + FileManager::TSP_DATA_FILEAREA_NAME, + '/1/2/3/9999999/'.FileManager::TSP_DATA_REPLY_FILENAME + ); + } + + /** + * Tests sending virtual file with invalid filename. + * + * @covers \quiz_archiver\FileManager::send_virtual_file + * @covers \quiz_archiver\FileManager::send_virtual_file_tsp + * @covers \quiz_archiver\FileManager::filearea_is_virtual + * + * @return void + * @throws \dml_exception + */ + public function test_send_virtual_files_invalid_filename(): void { + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $fm = new FileManager($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id); + + // Test missing job. + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Invalid filename/'); + $fm->send_virtual_file(FileManager::TSP_DATA_FILEAREA_NAME, '/0/0/0/0/secrets'); + } + + /** + * Test extracting the data of a single attempt from a job artifact file. + * + * @covers \quiz_archiver\FileManager::extract_attempt_data_from_artifact + * + * @return void + * @throws \coding_exception + * @throws \dml_exception + * @throws \file_exception + * @throws \moodle_exception + * @throws \stored_file_creation_exception + */ + public function test_extract_attempt_data_from_artifact(): void { + // Prepare a finished archive job that has a valid artifact file. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $job = ArchiveJob::create( + '00000000000000000000000042', + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + 2, + 0, + 'wstoken', + [], + [], + ArchiveJob::STATUS_FINISHED + ); + + $draftartifact = $this->getDataGenerator()->import_reference_quiz_artifact_as_draft(); + $attemptid = 13775; + + $fm = new FileManager($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id); + $fm->store_uploaded_artifact($draftartifact, $job->get_id()); + $storedartifacts = $fm->get_stored_artifacts(); + $storedartifact = array_shift($storedartifacts); + + // Extract userdata from artifact into temporary stored_file. + $tempfile = $fm->extract_attempt_data_from_artifact($storedartifact, $job->get_id(), $attemptid); + $this->assertNotEmpty($tempfile, 'No temp file was returned'); + $this->assertNotEmpty($tempfile->get_contenthash(), 'Temp file has no valid content hash'); + $this->assertTrue($tempfile->get_filesize() > 1024, 'Temp file is too small to be valid'); + } + + /** + * Test extracting a non-existing attempt from an artifact file. + * + * @covers \quiz_archiver\FileManager::extract_attempt_data_from_artifact + * + * @return void + * @throws \coding_exception + * @throws \dml_exception + * @throws \file_exception + * @throws \moodle_exception + * @throws \stored_file_creation_exception + */ + public function test_extract_attempt_data_for_nonexisting_attemptid(): void { + // Prepare a finished archive job that has a valid artifact file. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $job = ArchiveJob::create( + '00000000000000000000000021', + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + 2, + 0, + 'wstoken', + [], + [], + ArchiveJob::STATUS_FINISHED + ); + $draftartifact = $this->getDataGenerator()->import_reference_quiz_artifact_as_draft(); + $fm = new FileManager($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id); + $fm->store_uploaded_artifact($draftartifact, $job->get_id()); + $storedartifacts = $fm->get_stored_artifacts(); + $storedartifact = array_shift($storedartifacts); + + // Extract userdata from artifact into temporary stored_file. + $this->expectException(\moodle_exception::class); + $this->expectExceptionMessageMatches('/Attempt not found/'); + $fm->extract_attempt_data_from_artifact($storedartifact, $job->get_id(), 9999999); + } + + /** + * Test extracting userdata from an invalid artifact file. + * + * @covers \quiz_archiver\FileManager::extract_attempt_data_from_artifact + * + * @return void + * @throws \coding_exception + * @throws \dml_exception + * @throws \file_exception + * @throws \moodle_exception + * @throws \stored_file_creation_exception + */ + public function test_extract_attempt_data_from_invalid_artifact(): void { + // Prepare an unfinished archive job that has no artifact file. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $job = ArchiveJob::create( + '00000000000000000000000043', + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + 2, + 0, + 'wstoken', + [], + [], + ArchiveJob::STATUS_RUNNING + ); + $fm = new FileManager($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id); + + // Attempt to extract data from nonexisting artifact. + $this->expectException(\moodle_exception::class); + $this->expectExceptionMessageMatches('/Error processing archive file/'); + $fm->extract_attempt_data_from_artifact( + $this->getDataGenerator()->create_draft_file('not-an-artifact.tar.gz'), + $job->get_id(), + 1337 + ); + } + + /** + * Tests cleanup of temporary files produced by the attempt data extraction routine. + * + * @covers \quiz_archiver\FileManager::cleanup_temp_files + * + * @return void + * @throws \dml_exception + * @throws \file_exception + * @throws \stored_file_creation_exception + */ + public function test_cleanup_temp_files(): void { + // Prepare tempfiles. + $this->resetAfterTest(); + $overduetempfiles = [ + $this->getDataGenerator()->create_temp_file('tempfile1.tar.gz', 0), + $this->getDataGenerator()->create_temp_file('tempfile2.tar.gz', 0), + $this->getDataGenerator()->create_temp_file('tempfile3.tar.gz', 0), + ]; + $activetempfiles = [ + $this->getDataGenerator()->create_temp_file('tempfile4.tar.gz', time() + 3600), + $this->getDataGenerator()->create_temp_file('tempfile5.tar.gz', time() + 3600), + $this->getDataGenerator()->create_temp_file('tempfile6.tar.gz', time() + 3600), + ]; + + // Perform cleanup. + FileManager::cleanup_temp_files(); + + foreach ($overduetempfiles as $file) { + $this->assertEmpty(get_file_storage()->get_file_by_id($file->get_id()), 'Temp file was not deleted'); + } + + foreach ($activetempfiles as $file) { + $this->assertNotEmpty(get_file_storage()->get_file_by_id($file->get_id()), 'Active temp file was falsely deleted'); + } + } + +} diff --git a/tests/fixtures/archive_quiz_form_request_valid.json b/tests/fixtures/archive_quiz_form_request_valid.json new file mode 100644 index 0000000..7164f4f --- /dev/null +++ b/tests/fixtures/archive_quiz_form_request_valid.json @@ -0,0 +1,69 @@ +{ + "id": "23", + "mode": "archiver", + "mform_isexpanded_id_header_advanced_settings": "1", + "archive_retention_time": "94608000", + "sesskey": "0", + "_qf__quiz_archiver_form_archive_quiz_form": "1", + "mform_isexpanded_id_header_settings": "1", + "export_attempts": "1", + "export_report_section_header": [ + "0", + "1" + ], + "export_report_section_quiz_feedback": [ + "0", + "1" + ], + "export_report_section_question": [ + "0", + "1" + ], + "export_report_section_question_feedback": [ + "0", + "1" + ], + "export_report_section_general_feedback": [ + "0", + "1" + ], + "export_report_section_rightanswer": [ + "0", + "1" + ], + "export_report_section_history": [ + "0", + "1" + ], + "export_report_section_attachments": [ + "0", + "1" + ], + "export_quiz_backup": [ + "0", + "1" + ], + "export_course_backup": [ + "0", + "1" + ], + "export_attempts_paper_format": "A4", + "archive_filename_pattern": "quiz-archive-${courseshortname}-${courseid}-${quizname}-${quizid}_${date}-${time}", + "export_attempts_filename_pattern": "attempt-${attemptid}-${username}_${date}-${time}", + "export_attempts_image_optimize": [ + "0", + "1" + ], + "export_attempts_image_optimize_width": "1280", + "export_attempts_image_optimize_height": "1280", + "export_attempts_image_optimize_quality": "85", + "export_attempts_keep_html_files": [ + "0", + "1" + ], + "archive_autodelete": [ + "0", + "1" + ], + "submitbutton": "Archive+quiz" +} \ No newline at end of file diff --git a/tests/fixtures/cake.md b/tests/fixtures/cake.md new file mode 100644 index 0000000..374385d --- /dev/null +++ b/tests/fixtures/cake.md @@ -0,0 +1,6 @@ +# Recipe: Chocolate Cake + +- Chocolate +- Cake +- Love + diff --git a/tests/fixtures/referencequiz-artifact.tar.gz b/tests/fixtures/referencequiz-artifact.tar.gz new file mode 100644 index 0000000..f8b63dc Binary files /dev/null and b/tests/fixtures/referencequiz-artifact.tar.gz differ diff --git a/tests/fixtures/referencequiz.mbz b/tests/fixtures/referencequiz.mbz new file mode 100644 index 0000000..baa04cd Binary files /dev/null and b/tests/fixtures/referencequiz.mbz differ diff --git a/tests/form/archive_quiz_form_test.php b/tests/form/archive_quiz_form_test.php new file mode 100644 index 0000000..aa6b0cc --- /dev/null +++ b/tests/form/archive_quiz_form_test.php @@ -0,0 +1,265 @@ +. + +/** + * Tests for the archive_quiz_form + * + * @package quiz_archiver + * @copyright 2024 Niels Gandraß + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace quiz_archiver\form; + + +use quiz_archiver\ArchiveJob; + +/** + * Tests for the archive_quiz_form + */ +final class archive_quiz_form_test extends \advanced_testcase { + + /** + * Returns the data generator for the quiz_archiver plugin + * + * @return \quiz_archiver_generator The data generator for the quiz_archiver plugin + */ + // @codingStandardsIgnoreLine + public static function getDataGenerator(): \quiz_archiver_generator { + return parent::getDataGenerator()->get_plugin_generator('quiz_archiver'); + } + + /** + * Basic code coverage to verify validity of form definition and detect + * possible errors during form element definition. + * + * @covers \quiz_archiver\form\archive_quiz_form::__construct + * @covers \quiz_archiver\form\archive_quiz_form::definition + * + * @return void + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_form_definition(): void { + // Create a mock archive job. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $jobid = '10000000-0000-0000-0000-0123456789ab'; + ArchiveJob::create( + $jobid, + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + null, + 'TEST-WS-TOKEN-1', + $mocks->attempts, + $mocks->settings + ); + + // Create the form and define it. + $form = new archive_quiz_form($mocks->quiz->name, count($mocks->attempts)); + $this->assertInstanceOf(\moodleform::class, $form); + } + + /** + * Basic code coverage to verify validity of form definition and detect + * possible errors during form element definition with locked job presets. + * + * @covers \quiz_archiver\form\archive_quiz_form::__construct + * @covers \quiz_archiver\form\archive_quiz_form::definition + * + * @return void + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_form_definition_all_locked(): void { + // Create a mock archive job. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $jobid = '10000001-0000-0000-0000-0123456789ab'; + ArchiveJob::create( + $jobid, + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + 3600, + 'TEST-WS-TOKEN-1', + $mocks->attempts, + $mocks->settings + ); + + // Lock all lockable settings. + foreach (get_config('quiz_archiver') as $key => $value) { + if (strpos($key, '_locked') !== false) { + set_config($key, 1, 'quiz_archiver'); + } + } + + // Create the form and define it. + $form = new archive_quiz_form($mocks->quiz->name, count($mocks->attempts)); + $this->assertInstanceOf(\moodleform::class, $form); + } + + /** + * Test the custom form validation + * + * @dataProvider form_validation_data_provider + * @covers \quiz_archiver\form\archive_quiz_form::validation + * + * @param array $formdata + * @param bool $isvalid + * @return void + * @throws \coding_exception + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_form_validation(array $formdata, bool $isvalid): void { + // Create a mock archive job. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $jobid = '20000000-0000-0000-0000-0123456789ab'; + ArchiveJob::create( + $jobid, + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + null, + 'TEST-WS-TOKEN-1', + $mocks->attempts, + $mocks->settings + ); + + // Test that invalid filename patterns are filtered out. + $form = new archive_quiz_form($mocks->quiz->name, count($mocks->attempts)); + $errors = $form->validation($formdata, []); + if ($isvalid) { + $this->assertEmpty($errors, 'Form validation failed for valid input data.'); + } else { + $this->assertNotEmpty($errors, 'Form validation succeeded for invalid input data.'); + } + } + + /** + * Data provider for test_form_validation + * + * @return array[] Test data + */ + public static function form_validation_data_provider(): array { + return [ + 'Valid data' => [ + [ + 'archive_filename_pattern' => 'archive-${courseshortname}', + 'export_attempts_filename_pattern' => 'attempt-${attemptid}', + ], + true, + ], + 'Invalid archive filename pattern' => [ + [ + 'archive_filename_pattern' => 'archive-${courseshortname', + 'export_attempts_filename_pattern' => 'attempt-${attemptid}', + ], + false, + ], + 'Invalid attempt filename pattern' => [ + [ + 'archive_filename_pattern' => 'archive-${courseshortname}', + 'export_attempts_filename_pattern' => 'attempt-${attemptid', + ], + false, + ], + ]; + } + + /** + * Test custom form data overrides. Tests that locked settings can not be + * overridden by spoofed POST data. + * + * @dataProvider get_data_data_provider + * @covers \quiz_archiver\form\archive_quiz_form::get_data + * + * @param string $optionkey Job option key to test + * @param mixed $optionpresetvalue Preset value for the job option + * @param mixed $postvalue Value to be provided via POST + * @return void + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_get_data(string $optionkey, $optionpresetvalue, $postvalue): void { + global $USER; + + // Create a mock archive job. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $jobid = '30000000-0000-0000-0000-0123456789ab'; + ArchiveJob::create( + $jobid, + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + null, + 'TEST-WS-TOKEN-1', + $mocks->attempts, + $mocks->settings + ); + + // Prepare locked preset value. + set_config("job_preset_{$optionkey}", $optionpresetvalue, 'quiz_archiver'); + set_config("job_preset_{$optionkey}_locked", 1, 'quiz_archiver'); + + // Load valid form POST data and create form. + $USER->ignoresesskey = true; + $validpostdata = json_decode( + file_get_contents(__DIR__.'/../fixtures/archive_quiz_form_request_valid.json'), + true + ); + foreach ($validpostdata as $key => $value) { + $_POST[$key] = $value; + } + $_POST[$optionkey] = $postvalue; + $form = new archive_quiz_form($mocks->quiz->name, count($mocks->attempts)); + + // Verify that the preset value is locked and cannot be overridden, even if different data is provided via POST. + $this->assertEquals( + $optionpresetvalue, + $form->get_data()->{$optionkey}, + "Preset value for {$optionkey} was overridden even though is is locked." + ); + } + + /** + * Data provider for test_get_data + * + * @return array[] Test data + */ + public static function get_data_data_provider(): array { + return [ + 'Job preset locked: Export quiz attempts' => ['export_attempts', '1', '0'], + 'Job preset locked: Include correct answers' => ['export_report_section_rightanswer', '0', '1'], + 'Job preset locked: Include answer history' => ['export_report_section_history', '0', '1'], + 'Job preset locked: Include file attachments' => ['export_report_section_attachments', '1', '0'], + 'Job preset locked: Export quiz backup' => ['export_quiz_backup', '1', '0'], + 'Job preset locked: Export course backup' => ['export_course_backup', '0', '1'], + 'Job preset locked: Optimize images' => ['export_attempts_image_optimize', '1', '0'], + 'Job preset locked: Automatic deletion' => ['archive_autodelete', '0', '1'], + 'Job preset locked: Retention time' => ['archive_retention_time', '315360000', '60'], + ]; + } + +} diff --git a/tests/form/artifact_delete_form_test.php b/tests/form/artifact_delete_form_test.php new file mode 100644 index 0000000..33e44b6 --- /dev/null +++ b/tests/form/artifact_delete_form_test.php @@ -0,0 +1,129 @@ +. + +/** + * Tests for the artifact_delete_form + * + * @package quiz_archiver + * @copyright 2024 Niels Gandraß + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace quiz_archiver\form; + + +use quiz_archiver\ArchiveJob; + +/** + * Tests for the artifact_delete_form + */ +final class artifact_delete_form_test extends \advanced_testcase { + + /** + * Returns the data generator for the quiz_archiver plugin + * + * @return \quiz_archiver_generator The data generator for the quiz_archiver plugin + */ + // @codingStandardsIgnoreLine + public static function getDataGenerator(): \quiz_archiver_generator { + return parent::getDataGenerator()->get_plugin_generator('quiz_archiver'); + } + + /** + * Basic code coverage to verify validity of form definition and detect + * possible errors during form element definition. + * + * @covers \quiz_archiver\form\artifact_delete_form::__construct + * @covers \quiz_archiver\form\artifact_delete_form::definition + * + * @return void + * @throws \dml_exception + * @throws \file_exception + * @throws \moodle_exception + * @throws \stored_file_creation_exception + */ + public function test_form_definition(): void { + // Create a mock archive job. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $jobid = '10000000-0000-0000-0000-0123456789ab'; + $job = ArchiveJob::create( + $jobid, + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + 3600, + 'TEST-WS-TOKEN-1', + $mocks->attempts, + $mocks->settings + ); + + // Create a mock artifact file. + $artifact = $this->getDataGenerator()->create_artifact_file( + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + 'testartifact.tar.gz' + ); + $sha256dummy = hash('sha256', 'foo bar baz'); + $job->link_artifact($artifact->get_id(), $sha256dummy); + $job->set_status(ArchiveJob::STATUS_FINISHED); + + // Create the form and define it. + $_POST['jobid'] = $jobid; + $form = new artifact_delete_form(); + $this->assertInstanceOf(\moodleform::class, $form); + } + + /** + * Basic code coverage to verify validity of form definition and detect + * possible errors during form element definition for jobs without + * quiz archives. + * + * @covers \quiz_archiver\form\artifact_delete_form::__construct + * @covers \quiz_archiver\form\artifact_delete_form::definition + * + * @return void + * @throws \dml_exception + * @throws \file_exception + * @throws \moodle_exception + * @throws \stored_file_creation_exception + */ + public function test_form_definition_no_archive(): void { + // Create a mock archive job. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $jobid = '20000000-0000-0000-0000-0123456789ab'; + ArchiveJob::create( + $jobid, + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + 3600, + 'TEST-WS-TOKEN-1', + $mocks->attempts, + $mocks->settings + ); + + // Create the form and define it. + $_POST['jobid'] = $jobid; + $form = new artifact_delete_form(); + $this->assertInstanceOf(\moodleform::class, $form); + } + +} diff --git a/tests/form/autoinstall_form_test.php b/tests/form/autoinstall_form_test.php new file mode 100644 index 0000000..142aa34 --- /dev/null +++ b/tests/form/autoinstall_form_test.php @@ -0,0 +1,47 @@ +. + +/** + * Tests for the autoinstall_form + * + * @package quiz_archiver + * @copyright 2024 Niels Gandraß + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace quiz_archiver\form; + + +/** + * Tests for the autoinstall_form + */ +final class autoinstall_form_test extends \advanced_testcase { + + /** + * Basic code coverage to verify validity of form definition and detect + * possible errors during form element definition. + * + * @covers \quiz_archiver\form\autoinstall_form::__construct + * @covers \quiz_archiver\form\autoinstall_form::definition + * + * @return void + */ + public function test_form_definition(): void { + $form = new autoinstall_form(); + $this->assertInstanceOf(\moodleform::class, $form); + } + +} diff --git a/tests/form/job_delete_form_test.php b/tests/form/job_delete_form_test.php new file mode 100644 index 0000000..6c4f812 --- /dev/null +++ b/tests/form/job_delete_form_test.php @@ -0,0 +1,90 @@ +. + +/** + * Tests for the job_delete_form + * + * @package quiz_archiver + * @copyright 2024 Niels Gandraß + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace quiz_archiver\form; + + +use quiz_archiver\ArchiveJob; + +/** + * Tests for the job_delete_form + */ +final class job_delete_form_test extends \advanced_testcase { + + /** + * Returns the data generator for the quiz_archiver plugin + * + * @return \quiz_archiver_generator The data generator for the quiz_archiver plugin + */ + // @codingStandardsIgnoreLine + public static function getDataGenerator(): \quiz_archiver_generator { + return parent::getDataGenerator()->get_plugin_generator('quiz_archiver'); + } + + /** + * Basic code coverage to verify validity of form definition and detect + * possible errors during form element definition. + * + * @covers \quiz_archiver\form\job_delete_form::__construct + * @covers \quiz_archiver\form\job_delete_form::definition + * + * @return void + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_form_definition(): void { + // Create a mock archive job. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $jobid = '10000000-0000-0000-0000-0123456789ab'; + $job = ArchiveJob::create( + $jobid, + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + $mocks->user->id, + 3600, + 'TEST-WS-TOKEN-1', + $mocks->attempts, + $mocks->settings + ); + + // Create a mock artifact file. + $artifact = $this->getDataGenerator()->create_artifact_file( + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + 'testartifact.tar.gz' + ); + $sha256dummy = hash('sha256', 'foo bar baz'); + $job->link_artifact($artifact->get_id(), $sha256dummy); + $job->set_status(ArchiveJob::STATUS_FINISHED); + + // Create and define the form. + $_POST['jobid'] = $jobid; + $form = new job_delete_form(); + $this->assertInstanceOf(\moodleform::class, $form); + } + +} diff --git a/tests/form/job_sign_form_test.php b/tests/form/job_sign_form_test.php new file mode 100644 index 0000000..ce4e235 --- /dev/null +++ b/tests/form/job_sign_form_test.php @@ -0,0 +1,49 @@ +. + +/** + * Tests for the job_sign_form + * + * @package quiz_archiver + * @copyright 2024 Niels Gandraß + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace quiz_archiver\form; + + +/** + * Tests for the job_sign_form + */ +final class job_sign_form_test extends \advanced_testcase { + + /** + * Basic code coverage to verify validity of form definition and detect + * possible errors during form element definition. + * + * @covers \quiz_archiver\form\job_sign_form::__construct + * @covers \quiz_archiver\form\job_sign_form::definition + * + * @return void + */ + public function test_form_definition(): void { + // Create the form and define it. + $_POST['jobid'] = '10000000-0000-0000-0000-0123456789ab'; + $form = new job_sign_form(); + $this->assertInstanceOf(\moodleform::class, $form); + } + +} diff --git a/tests/generator/lib.php b/tests/generator/lib.php new file mode 100644 index 0000000..ded1417 --- /dev/null +++ b/tests/generator/lib.php @@ -0,0 +1,286 @@ +. + +use quiz_archiver\FileManager; + +// @codingStandardsIgnoreLine +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore + +global $CFG; // @codeCoverageIgnore +require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); // @codeCoverageIgnore + +/** + * Tests generator for the quiz_archiver plugin + * + * @package quiz_archiver + * @copyright 2024 Niels Gandraß + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class quiz_archiver_generator extends \testing_data_generator { + + /** @var string[] Question types present in the reference quiz */ + const QUESTION_TYPES_IN_REFERENCE_QUIZ = [ + 'description', + 'multichoice', + 'truefalse', + 'match', + 'shortanswer', + 'numerical', + 'essay', + 'calculated', + 'calculatedmulti', + 'calculatedsimple', + 'ddwtos', + 'ddmarker', + 'ddimageortext', + 'multianswer', + 'gapselect', + ]; + + /** + * Creates a course that contains a quiz module as a new user. + * + * @return stdClass The user, course and quiz, as well as mock attempts and + * archive job settings. + */ + public function create_mock_quiz(): \stdClass { + // Prepare user and course. + $user = $this->create_user(); + $course = $this->create_course(); + $quiz = $this->create_module('quiz', [ + 'course' => $course->id, + 'grade' => 100.0, + 'sumgrades' => 100, + ]); + + return (object) [ + 'user' => $user, + 'course' => $course, + 'quiz' => $quiz, + 'attempts' => [ + (object) ['userid' => 1, 'attemptid' => 1], + (object) ['userid' => 2, 'attemptid' => 42], + (object) ['userid' => 3, 'attemptid' => 1337], + ], + 'settings' => [ + 'num_attempts' => 3, + 'export_attempts' => 1, + 'export_report_section_header' => 1, + 'export_report_section_quiz_feedback' => 1, + 'export_report_section_question' => 1, + 'export_report_section_question_feedback' => 0, + 'export_report_section_general_feedback' => 1, + 'export_report_section_rightanswer' => 0, + 'export_report_section_history' => 1, + 'export_report_section_attachments' => 1, + 'export_quiz_backup' => 1, + 'export_course_backup' => 0, + 'archive_autodelete' => 1, + 'archive_retention_time' => '42w', + ], + ]; + } + + /** + * Generates a dummy artifact file, stored in the context of the given course. + * + * @param int $courseid ID of the course to store the file in + * @param int $cmid ID of the course module to store the file in + * @param int $quizid ID of the quiz to store the file in + * @param string $filename Name of the file to create + * @return \stored_file The created file handle + * @throws \file_exception + * @throws \stored_file_creation_exception + */ + public function create_artifact_file(int $courseid, int $cmid, int $quizid, string $filename): \stored_file { + $ctx = context_course::instance($courseid); + + return get_file_storage()->create_file_from_string( + [ + 'contextid' => $ctx->id, + 'component' => FileManager::COMPONENT_NAME, + 'filearea' => FileManager::ARTIFACTS_FILEAREA_NAME, + 'itemid' => 0, + 'filepath' => "/{$courseid}/{$cmid}/{$quizid}/", + 'filename' => $filename, + 'timecreated' => time(), + 'timemodified' => time(), + ], + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do '. + 'eiusmod tempor incididunt ut labore et dolore magna aliqua.' + ); + } + + /** + * Generates a dummy draft file, stored in the given filearea (default: user + * draft filearea). + * + * @param string $filename Name of the file to create + * @param string $filearea Filearea to store the file in + * @return \stored_file The created file handle + * @throws \file_exception + * @throws \stored_file_creation_exception + */ + public function create_draft_file(string $filename, string $filearea = 'draft'): \stored_file { + $ctx = context_user::instance($this->create_user()->id); + + return get_file_storage()->create_file_from_string( + [ + 'contextid' => $ctx->id, + 'component' => 'user', + 'filearea' => $filearea, + 'itemid' => 0, + 'filepath' => "/", + 'filename' => $filename, + 'timecreated' => time(), + 'timemodified' => time(), + ], + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do '. + 'eiusmod tempor incididunt ut labore et dolore magna aliqua.' + ); + } + + /** + * Imports the reference course into a new course and returns the reference + * quiz, the respective cm, and the course itself. + * + * @throws \restore_controller_exception + * @throws \dml_exception + * @throws \moodle_exception + * @return \stdClass Object with keys 'quiz' (the reference quiz), 'cm' (the + * respective cm), 'course' (the course itself), 'attemptids' (array of all + * attempt ids inside the reference quiz), 'userids' (array of all user ids + * with attempts in the reference quiz) + */ + public function import_reference_course(): \stdClass { + global $DB; + + // Prepare backup of reference course for restore. + $backupid = 'referencequiz'; + $backuppath = make_backup_temp_directory($backupid); + get_file_packer('application/vnd.moodle.backup')->extract_to_pathname( + __DIR__."/../fixtures/referencequiz.mbz", + $backuppath + ); + + // Restore reference course as a new course with default settings. + $categoryid = $DB->get_field('course_categories', 'MIN(id)', []); + $newcourseid = \restore_dbops::create_new_course('Reference Course', 'REF', $categoryid); + $rc = new \restore_controller( + $backupid, + $newcourseid, + \backup::INTERACTIVE_NO, + \backup::MODE_GENERAL, + get_admin()->id, + \backup::TARGET_NEW_COURSE + ); + + if (!$rc->execute_precheck()) { + throw new \restore_controller_exception('Backup restore precheck failed.'); // @codeCoverageIgnore + } + $rc->execute_plan(); + if ($rc->get_status() != backup::STATUS_FINISHED_OK) { + throw new \restore_controller_exception('Restore of reference course failed.'); // @codeCoverageIgnore + } + + // 2024-05-14: Do not destroy restore_controller. This will drop temptables without removing them from + // $DB->temptables properly, causing DB reset to fail in subsequent tests due to missing tables. Destroying the + // restore_controller is optional and not necessary for this test. + // $rc->destroy();. + + // Get course and find the reference quiz. + $course = get_course($rc->get_courseid()); + $modinfo = get_fast_modinfo($course); + $cms = $modinfo->get_cms(); + $cm = null; + foreach ($cms as $curcm) { + if ($curcm->modname == 'quiz' && strpos($curcm->name, 'Reference Quiz') === 0) { + $cm = $curcm; + break; + } + } + $quiz = $DB->get_record('quiz', ['id' => $cm->instance], '*', MUST_EXIST); + $attemptids = array_values(array_map( + fn($r): int => $r->id, + $DB->get_records('quiz_attempts', ['quiz' => $quiz->id], '', 'id') + )); + + $userids = array_values(array_map( + fn($r): int => $r->userid, + $DB->get_records('quiz_attempts', ['quiz' => $quiz->id], '', 'userid') + )); + + return (object) [ + 'course' => $course, + 'cm' => $cm, + 'quiz' => $quiz, + 'attemptids' => $attemptids, + 'userids' => $userids, + ]; + } + + /** + * Imports the reference quiz artifact from the test fixtures directory into + * the a Moodle stored_file residing inside a users draft filearea. + * + * @return \stored_file + * @throws \file_exception + * @throws \stored_file_creation_exception + */ + public function import_reference_quiz_artifact_as_draft(): \stored_file { + $ctx = context_user::instance($this->create_user()->id); + + return get_file_storage()->create_file_from_pathname([ + 'contextid' => $ctx->id, + 'component' => 'user', + 'filearea' => 'draft', + 'itemid' => 0, + 'filepath' => "/", + 'filename' => 'reference_quiz_artifact.tar.gz', + 'timecreated' => time(), + 'timemodified' => time(), + ], __DIR__.'/../fixtures/referencequiz-artifact.tar.gz'); + } + + /** + * Generates a dummy file inside the temp filearea of this plugin. + * + * @param string $filename + * @param int $expiry + * @return \stored_file + * @throws \file_exception + * @throws \stored_file_creation_exception + */ + public function create_temp_file(string $filename, int $expiry): \stored_file { + $ctx = context_user::instance($this->create_user()->id); + + return get_file_storage()->create_file_from_string( + [ + 'contextid' => $ctx->id, + 'component' => FileManager::COMPONENT_NAME, + 'filearea' => FileManager::TEMP_FILEAREA_NAME, + 'itemid' => 0, + 'filepath' => '/'.$expiry.'/', + 'filename' => $filename, + 'timecreated' => time(), + 'timemodified' => time(), + ], + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do '. + 'eiusmod tempor incididunt ut labore et dolore magna aliqua.' + ); + } + +} diff --git a/tests/generator_test.php b/tests/generator_test.php new file mode 100644 index 0000000..83670e6 --- /dev/null +++ b/tests/generator_test.php @@ -0,0 +1,239 @@ +. + +/** + * Tests for the quiz_archiver test data generator + * + * @package quiz_archiver + * @copyright 2024 Niels Gandraß + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace quiz_archiver; + + +/** + * Tests for the quiz_archiver_generator class + */ +final class generator_test extends \advanced_testcase { + + /** + * Returns the data generator for the quiz_archiver plugin + * + * @return \quiz_archiver_generator The data generator for the quiz_archiver plugin + */ + // @codingStandardsIgnoreLine + public static function getDataGenerator(): \quiz_archiver_generator { + return parent::getDataGenerator()->get_plugin_generator('quiz_archiver'); + } + + /** + * Tests that the generator can create a mock quiz + * + * @covers \quiz_archiver_generator::create_mock_quiz + * + * @return void + * @throws \dml_exception + */ + public function test_create_mock_quiz(): void { + global $DB; + + // Generate mock quiz. + $generator = self::getDataGenerator(); + $this->resetAfterTest(); + $mocks = $generator->create_mock_quiz(); + + // Test generic object. + $this->assertNotEmpty($mocks, 'The mocks were not created'); + + // Check user. + $this->assertNotEmpty($mocks->user, 'The user was not created'); + $this->assertNotEmpty($DB->get_record('user', ['id' => $mocks->user->id]), 'The user was not created correctly'); + + // Check course. + $this->assertNotEmpty($mocks->course, 'The course was not created'); + $this->assertNotEmpty($DB->get_record('course', ['id' => $mocks->course->id]), 'The course was not created correctly'); + + // Check quiz. + $this->assertNotEmpty($mocks->quiz, 'The quiz was not created'); + $this->assertNotEmpty($DB->get_record('quiz', ['id' => $mocks->quiz->id]), 'The quiz was not created correctly'); + + // Check attempts and settings. + $this->assertCount(3, $mocks->attempts, 'The mock attempts were not created correctly'); + $this->assertSame(count($mocks->attempts), $mocks->settings['num_attempts'], 'The job settings attempt count is incorrect'); + $this->assertGreaterThan(10, count($mocks->settings), 'The job settings are incomplete'); + } + + /** + * Tests the creation of an artifact file associated with a quiz + * + * @covers \quiz_archiver_generator::create_artifact_file + * + * @return void + * @throws \file_exception + * @throws \stored_file_creation_exception + */ + public function test_create_artifact_file(): void { + // Create mock quiz and artifact file. + $generator = self::getDataGenerator(); + $this->resetAfterTest(); + $mocks = $generator->create_mock_quiz(); + $artifact = $generator->create_artifact_file($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id, 'testfile.txt'); + + // Verify artifact file. + $this->assertNotEmpty($artifact, 'The artifact file was not created'); + $this->assertEquals('testfile.txt', $artifact->get_filename(), 'The artifact file has the wrong filename'); + $this->assertEquals( + FileManager::COMPONENT_NAME, + $artifact->get_component(), + 'The artifact file has the wrong component' + ); + $this->assertEquals( + FileManager::ARTIFACTS_FILEAREA_NAME, + $artifact->get_filearea(), + 'The artifact file has the wrong filearea' + ); + $this->assertEquals( + "/{$mocks->course->id}/{$mocks->quiz->cmid}/{$mocks->quiz->id}/", + $artifact->get_filepath(), + 'The artifact file has the wrong filepath' + ); + $this->assertStringContainsString( + 'Lorem ipsum dolor sit amet', + $artifact->get_content(), + 'The artifact file has the wrong content' + ); + } + + /** + * Tests the creation of a draft file + * + * @covers \quiz_archiver_generator::create_draft_file + * + * @return void + * @throws \file_exception + * @throws \stored_file_creation_exception + */ + public function test_create_draft_file(): void { + // Create new draft file. + $generator = self::getDataGenerator(); + $this->resetAfterTest(); + $draftfile = $generator->create_draft_file('drafttestfile.txt'); + + // Verify draft file. + $this->assertNotEmpty($draftfile, 'The draft file was not created'); + $this->assertEquals('drafttestfile.txt', $draftfile->get_filename(), 'The draft file has the wrong filename'); + $this->assertEquals('user', $draftfile->get_component(), 'The draft file has the wrong component'); + $this->assertEquals('draft', $draftfile->get_filearea(), 'The draft file has the wrong filearea'); + $this->assertStringContainsString( + 'Lorem ipsum dolor sit amet', + $draftfile->get_content(), + 'The draft file has the wrong content' + ); + } + + /** + * Tests the import of the reference course + * + * @covers \quiz_archiver_generator::import_reference_course + * + * @return void + * @throws \dml_exception + * @throws \moodle_exception + * @throws \restore_controller_exception + */ + public function test_import_reference_course(): void { + // Import reference course. + $generator = self::getDataGenerator(); + $this->resetAfterTest(); + $rc = $generator->import_reference_course(); + + // Verify reference quiz. + $this->assertNotEmpty($rc, 'The reference quiz was not imported'); + $this->assertNotEmpty($rc->course, 'The reference course was not imported'); + $this->assertNotEmpty($rc->cm, 'The reference course module was not imported'); + $this->assertNotEmpty($rc->quiz, 'The reference quiz was not imported'); + $this->assertCount(1, $rc->attemptids, 'The reference quiz attempts were not imported'); + $this->assertCount(1, $rc->userids, 'The reference user IDs were not imported'); + + $this->assertStringContainsString('Reference Quiz', $rc->quiz->name, 'The reference quiz has the wrong name'); + } + + /** + * Tests the import of the reference quiz artifact file into the draft + * filearea + * + * @covers \quiz_archiver_generator::import_reference_quiz_artifact_as_draft + * + * @return void + * @throws \file_exception + * @throws \stored_file_creation_exception + */ + public function test_import_reference_quiz_artifact_as_draft(): void { + // Import reference quiz artifact as draft. + $generator = self::getDataGenerator(); + $this->resetAfterTest(); + $artifact = $generator->import_reference_quiz_artifact_as_draft(); + + // Verify artifact file. + $this->assertNotEmpty($artifact, 'The artifact file was not imported'); + $this->assertEquals( + 'reference_quiz_artifact.tar.gz', + $artifact->get_filename(), + 'The artifact file has the wrong filename' + ); + $this->assertEquals( + 'user', + $artifact->get_component(), + 'The artifact file has the wrong component' + ); + $this->assertEquals( + 'draft', + $artifact->get_filearea(), + 'The artifact file has the wrong filearea' + ); + $this->assertGreaterThan(16384, $artifact->get_filesize(), 'The artifact file is too small'); + } + + /** + * Tests the creation of a temp file with an expiry date + * + * @covers \quiz_archiver_generator::create_temp_file + * + * @return void + * @throws \file_exception + * @throws \stored_file_creation_exception + */ + public function test_create_temp_file(): void { + // Create a new temp file with an expiry date. + $generator = self::getDataGenerator(); + $this->resetAfterTest(); + $tempfile = $generator->create_temp_file('tempfile.txt', 1337); + + // Verify temp file. + $this->assertNotEmpty($tempfile, 'The temp file was not created'); + $this->assertEquals('tempfile.txt', $tempfile->get_filename(), 'The temp file has the wrong filename'); + $this->assertEquals(FileManager::COMPONENT_NAME, $tempfile->get_component(), 'The temp file has the wrong component'); + $this->assertEquals(FileManager::TEMP_FILEAREA_NAME, $tempfile->get_filearea(), 'The temp file has the wrong filearea'); + $this->assertEquals('/1337/', $tempfile->get_filepath(), 'The temp file has the wrong filepath / expiry date'); + $this->assertStringContainsString( + 'Lorem ipsum dolor sit amet', + $tempfile->get_content(), + 'The temp file has the wrong content' + ); + } + +} diff --git a/tests/local/autoinstall_test.php b/tests/local/autoinstall_test.php new file mode 100644 index 0000000..5651570 --- /dev/null +++ b/tests/local/autoinstall_test.php @@ -0,0 +1,168 @@ +. + +/** + * Tests for the autoinstall class + * + * @package quiz_archiver + * @copyright 2024 Niels Gandraß + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace quiz_archiver\local; + +/** + * Tests for the autoinstall class + */ +final class autoinstall_test extends \advanced_testcase { + + /** + * Tests that the autoinstall process checks user privileges + * + * @covers \quiz_archiver\local\autoinstall::execute + * + * @return void + */ + public function test_autoinstall_requires_admin(): void { + $this->resetAfterTest(); + list($success, $log) = autoinstall::execute('http://foo.bar:1337'); + $this->assertFalse($success, 'Autoinstall was successful without admin privileges'); + $this->assertStringContainsString('Error: You need to be a site administrator', $log, 'Error message was not displayed'); + } + + /** + * Test one full autoinstall process + * + * @covers \quiz_archiver\local\autoinstall::execute + * + * @return void + * @throws \dml_exception + */ + public function test_autoinstall(): void { + global $DB; + $this->resetAfterTest(); + + // Gain privileges. + $this->setAdminUser(); + + // Execute autoinstall. + $workerurl = 'http://foo.bar:1337'; + $wsname = 'test_webservice_name'; + $rolename = 'test_role_name'; + $username = 'test_user_name'; + + list($success, $log) = autoinstall::execute( + $workerurl, + $wsname, + $rolename, + $username + ); + + // Check function return. + $this->assertTrue($success, 'Autoinstall returned success=false'); + $this->assertNotEmpty($log, 'Autoinstall returned empty log'); + + // Check worker URL. + $this->assertSame($workerurl, get_config('quiz_archiver', 'worker_url'), 'Worker URL was not set correctly'); + + // Check global config. + $this->assertEquals( // This can not be assertTrue, since Moodle stores a '1'. + true, + get_config('moodle', 'enablewebservices'), + 'Webservices were not globally enabled' + ); + $this->assertStringContainsString( + 'rest', + get_config('moodle', 'webserviceprotocols'), + 'REST protocol was not globally enabled' + ); + + // Check webservice. + $webservice = $DB->get_record('external_services', ['name' => $wsname]); + $this->assertNotEmpty($webservice, 'Webservice was not created'); + $this->assertSame($webservice->name, $wsname, 'Webservice name was not set correctly'); + $this->assertNotEmpty( + $DB->get_records('external_services_functions', ['externalserviceid' => $webservice->id]), + 'Webservice functions were not assigned' + ); + $this->assertSame( + $webservice->id, + get_config('quiz_archiver', 'webservice_id'), + 'Webservice ID was not set correctly' + ); + + // Check role. + $role = $DB->get_record('role', ['shortname' => $rolename]); + $this->assertNotEmpty($role, 'Role was not created'); + $this->assertNotEmpty( + $DB->get_records('role_capabilities', ['roleid' => $role->id]), + 'Role capabilities were not assigned' + ); + + // Check user. + $user = $DB->get_record('user', ['username' => $username]); + $this->assertNotEmpty($user, 'User was not created'); + $this->assertNotEmpty( + $DB->get_records('role_assignments', ['userid' => $user->id, 'roleid' => $role->id]), + 'User role was not assigned' + ); + $this->assertSame($user->id, get_config('quiz_archiver', 'webservice_userid'), 'User ID was not set correctly'); + } + + /** + * Tests if autoinstalls are properly detected and repeated autoinstalls + * are prevented. + * + * @covers \quiz_archiver\local\autoinstall::plugin_is_unconfigured + * @covers \quiz_archiver\local\autoinstall::execute + * + * @return void + * @throws \dml_exception + */ + public function test_autoinstall_detection(): void { + $this->resetAfterTest(); + + // Gain privileges. + $this->setAdminUser(); + + // Plugin should be unconfigured. + $this->assertTrue(autoinstall::plugin_is_unconfigured(), 'Plugin was not unconfigured'); + + // Perform autoinstall. + list($success, $log) = autoinstall::execute('http://foo.bar:1337'); + $this->assertTrue($success, 'First autoinstall failed'); + + // Try to detect autoinstall. + $this->assertFalse(autoinstall::plugin_is_unconfigured(), 'Successful autoinstall was not detected'); + + // Try to autoinstall a second time. + list($success, $log) = autoinstall::execute('http://foo.bar:1337'); + $this->assertFalse($success, 'Second autoinstall was successful'); + $this->assertNotEmpty($log, 'Second autoinstall returned empty log'); + + // Try with force. + list($success, $log) = autoinstall::execute( + 'http://foo.bar:1337', + 'anotherwsname', + 'anotherroleshortname', + 'anotherusername', + true + ); + $this->assertTrue($success, 'Forced autoinstall failed'); + $this->assertNotEmpty($log, 'Forced autoinstall returned empty log'); + } + +} diff --git a/tests/local/util_test.php b/tests/local/util_test.php new file mode 100644 index 0000000..24f6d96 --- /dev/null +++ b/tests/local/util_test.php @@ -0,0 +1,114 @@ +. + +/** + * Tests for the util class + * + * @package quiz_archiver + * @copyright 2024 Niels Gandraß + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace quiz_archiver\local; + +/** + * Tests for the autoinstall class + */ +final class util_test extends \advanced_testcase { + + /** + * Tests the duration_to_human_readable util function + * + * @dataProvider duration_to_human_readable_data_provider + * @covers \quiz_archiver\local\util::duration_to_human_readable + * + * @param int $duration + * @param string $expected + * @return void + */ + public function test_duration_to_human_readable(int $duration, string $expected): void { + $this->assertEquals($expected, util::duration_to_human_readable($duration)); + } + + /** + * Data provider for test_duration_to_human_readable + * + * @return array[] Test data + */ + public static function duration_to_human_readable_data_provider(): array { + return [ + '0 seconds' => [0, '0s'], + '1 second' => [1, '1s'], + '59 seconds' => [59, '59s'], + '1 minute' => [MINSECS, '1m'], + '1 minute 1 second' => [MINSECS + 1, '1m 1s'], + '1 minute 59 seconds' => [MINSECS + 59, '1m 59s'], + '2 minutes' => [2 * MINSECS, '2m'], + '59 minutes 59 seconds' => [HOURSECS - 1, '59m 59s'], + '1 hour' => [HOURSECS, '1h'], + '1 hour 1 second' => [HOURSECS + 1, '1h 1s'], + '1 hour 1 minute' => [HOURSECS + MINSECS, '1h 1m'], + '1 hour 1 minute 1 second' => [HOURSECS + MINSECS + 1, '1h 1m 1s'], + '23 hours' => [23 * HOURSECS, '23h'], + '23 hours 59 minutes 59 seconds' => [DAYSECS - 1, '23h 59m 59s'], + '1 day' => [DAYSECS, '1d'], + '10 days' => [10 * DAYSECS, '10d'], + '1 month' => [YEARSECS / 12, '1m'], + '1 year' => [YEARSECS, '1y'], + '1 year 4 months 2 days 13 hours 37 minutes' => [ + YEARSECS + 4 * (YEARSECS / 12) + 2 * DAYSECS + 13 * HOURSECS + 37 * MINSECS, + '1y 4m 2d 13h 37m', + ], + ]; + } + + /** + * Tests the duration_to_unit util function + * + * @dataProvider duration_to_unit_data_provider + * @covers \quiz_archiver\local\util::duration_to_unit + * + * @param int $duration + * @param int $expectedvalue + * @param string $expectedunit + * @return void + * @throws \coding_exception + */ + public function test_duration_to_unit(int $duration, int $expectedvalue, string $expectedunit): void { + $this->assertEquals( + [$expectedvalue, get_string($expectedunit)], + util::duration_to_unit($duration) + ); + } + + /** + * Data provider for test_duration_to_unit + * + * @return array[] Test data + */ + public static function duration_to_unit_data_provider(): array { + return [ + '1 week' => [WEEKSECS, 1, 'weeks'], + '1 day' => [DAYSECS, 1, 'days'], + '1 hour' => [HOURSECS, 1, 'hours'], + '1 minute' => [MINSECS, 1, 'minutes'], + '1 second' => [1, 1, 'seconds'], + '61 seconds' => [61, 61, 'seconds'], + '42 hours' => [42 * HOURSECS, 42, 'hours'], + ]; + } + +} diff --git a/tests/output/job_overview_table_test.php b/tests/output/job_overview_table_test.php new file mode 100644 index 0000000..845b1ff --- /dev/null +++ b/tests/output/job_overview_table_test.php @@ -0,0 +1,102 @@ +. + +/** + * Tests for the job_overview_table + * + * @package quiz_archiver + * @copyright 2024 Niels Gandraß + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace quiz_archiver\output; + + +use quiz_archiver\ArchiveJob; + +/** + * Tests for the job_overview_table + */ +final class job_overview_table_test extends \advanced_testcase { + + /** + * Returns the data generator for the quiz_archiver plugin + * + * @return \quiz_archiver_generator The data generator for the quiz_archiver plugin + */ + // @codingStandardsIgnoreLine + public static function getDataGenerator(): \quiz_archiver_generator { + return parent::getDataGenerator()->get_plugin_generator('quiz_archiver'); + } + + /** + * Basic coverage test for table generation logic + * + * @covers \quiz_archiver\output\job_overview_table + * + * @return void + * @throws \coding_exception + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_table_generation(): void { + // Create a mock job to render inside the table. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); + $job = ArchiveJob::create( + '00000000000000000000000001', + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + 2, + 0, + 'wstoken', + [], + [], + ArchiveJob::STATUS_AWAITING_PROCESSING + ); + $job->set_status(ArchiveJob::STATUS_RUNNING, ['progress' => 42]); + + // Create a second job that is finished and has an artifact. + $job2 = ArchiveJob::create( + '00000000000000000000000002', + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + 2, + 0, + 'wstoken', + [], + [], + ); + $artifact = $this->getDataGenerator()->create_artifact_file( + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + 'testartifact.tar.gz' + ); + $job2->link_artifact($artifact->get_id(), 'sha256dummy'); + $job2->set_status(ArchiveJob::STATUS_FINISHED); + + // Create the table and render it. + $table = new job_overview_table(100000, $mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id); + $table->out(50, true); + + $this->assertdebuggingcalledcount(2); + $this->expectOutputRegex('/.*<\/table>/s'); + } + +} diff --git a/tests/remotearchiveworker_test.php b/tests/remotearchiveworker_test.php new file mode 100644 index 0000000..5456264 --- /dev/null +++ b/tests/remotearchiveworker_test.php @@ -0,0 +1,57 @@ +. + +/** + * Tests for the RemoteArchiveWorker class + * + * @package quiz_archiver + * @copyright 2024 Niels Gandraß + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace quiz_archiver; + +/** + * Tests for the RemoteArchiveWorker class + */ +final class remotearchiveworker_test extends \advanced_testcase { + + /** + * Test creation of request and interaction with the Moodle curl wrapper + * + * @covers \quiz_archiver\RemoteArchiveWorker::__construct + * @covers \quiz_archiver\RemoteArchiveWorker::enqueue_archive_job + * + * @return void + * @throws \dml_exception + */ + public function test_enqueue_archive_job_stub(): void { + $worker = new RemoteArchiveWorker('http://localhost:12345', 1, 1); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessageMatches('/archive worker response failed/'); + $worker->enqueue_archive_job( + 'invalid-wstoken', + -1, + -1, + -1, + [], + [], + [] + ); + } + +} diff --git a/tests/report_test.php b/tests/report_test.php new file mode 100644 index 0000000..e2422a4 --- /dev/null +++ b/tests/report_test.php @@ -0,0 +1,692 @@ +. + +/** + * Tests for the Report class + * + * @package quiz_archiver + * @copyright 2024 Niels Gandraß + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace quiz_archiver; + +use backup; + +// @codingStandardsIgnoreLine +global $CFG; + +require_once($CFG->dirroot . '/mod/quiz/report/archiver/patch_401_class_renames.php'); +require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); + +/** + * Tests for the Report class + */ +final class report_test extends \advanced_testcase { + + /** + * Returns the data generator for the quiz_archiver plugin + * + * @return \quiz_archiver_generator The data generator for the quiz_archiver plugin + */ + // @codingStandardsIgnoreLine + public static function getDataGenerator(): \quiz_archiver_generator { + return parent::getDataGenerator()->get_plugin_generator('quiz_archiver'); + } + + /** + * Generates an report section settings array with all sections enabled + * + * @return array To pass to Report::generate(), with all report sections enabled + */ + protected static function get_all_report_sections_enabled(): array { + $sections = []; + foreach (Report::SECTIONS as $section) { + $sections[$section] = true; + } + return $sections; + } + + /** + * Generates an archive_form formdata object with all report sections enabled + * + * @return \stdClass That emulates the data received from the archive_form + */ + protected static function get_formdata_all_reports_sections_enabled(): object { + $formdata = new \stdClass(); + foreach (Report::SECTIONS as $section) { + $formdata->{'export_report_section_'.$section} = 1; + } + return $formdata; + } + + /** + * Tests validation of webservice tokens + * + * @covers \quiz_archiver\Report::has_access + * + * @return void + * @throws \dml_exception + * @throws \moodle_exception + * @throws \restore_controller_exception + */ + public function test_webservice_token_access_validation(): void { + $this->resetAfterTest(); + $rc = $this->getDataGenerator()->import_reference_course(); + $report = new Report($rc->course, $rc->cm, $rc->quiz); + $validtoken = md5("VALID-TEST-TOKEN"); + $invalidtoken = md5("INVALID-TEST-TOKEN"); + $job = ArchiveJob::create( + 'test-job', + $rc->course->id, + $rc->cm->id, + $rc->quiz->id, + 2, + null, + $validtoken, + [], + [], + ); + + $this->assertTrue($report->has_access($validtoken), 'Valid token rejected'); + $this->assertFalse($report->has_access($invalidtoken), 'Invalid token accepted'); + + $job->set_status(ArchiveJob::STATUS_FINISHED); + $this->assertFalse($report->has_access($validtoken), 'Valid token accepted for finished job'); + $this->assertFalse($report->has_access($invalidtoken), 'Invalid token accepted for finished job'); + } + + /** + * Test generation of a full attempt report with all sections + * + * @covers \quiz_archiver\Report::__construct + * @covers \quiz_archiver\Report::generate + * + * @return void + * @throws \DOMException + * @throws \coding_exception + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_generate_full_report(): void { + $this->resetAfterTest(); + $rc = $this->getDataGenerator()->import_reference_course(); + + // Generate full report with all sections. + $report = new Report($rc->course, $rc->cm, $rc->quiz); + $html = $report->generate($rc->attemptids[0], self::get_all_report_sections_enabled()); + $this->assertNotEmpty($html, 'Generated report is empty'); + + // Verify quiz header. + $this->assertMatchesRegularExpression( + '/]*quizreviewsummary[^<>]*>/', + $html, + 'Quiz header table not found' + ); + $this->assertMatchesRegularExpression( + '/]*>' . preg_quote($rc->course->fullname, + '/') . '[^<>]+<\/td>/', + $html, 'Course name not found' + ); + $this->assertMatchesRegularExpression( + '/]*>' . preg_quote($rc->quiz->name, + '/') . '[^<>]+<\/td>/', + $html, 'Quiz name not found' + ); + + // Verify overall quiz feedback. + // TODO (MDL-0): Add proper overall feedback to reference quiz and check its contents. + $this->assertMatchesRegularExpression( + '/]*>\s*' . preg_quote(get_string('feedback', + 'quiz'), + '/' + ) . '\s*<\/th>/', $html, 'Overall feedback header not found'); + + // Verify questions. + foreach ($this->getDataGenerator()::QUESTION_TYPES_IN_REFERENCE_QUIZ as $qtype) { + $this->assertMatchesRegularExpression( + '/<[^<>]*class="[^\"<>]*que[^\"<>]*' . preg_quote($qtype, '/') . '[^\"<>]*"[^<>]*>/', + $html, + 'Question of type ' . $qtype . ' not found' + ); + } + + // Verify individual question feedback. + $this->assertMatchesRegularExpression( + '/
    /', + $html, + 'Individual question feedback not found' + ); + + // Verify general question feedback. + $this->assertMatchesRegularExpression( + '/
    /', + $html, + 'General question feedback not found' + ); + + // Verify correct answers. + $this->assertMatchesRegularExpression( + '/
    /', + $html, + 'Correct question answers not found' + ); + + // Verify answer history. + $this->assertMatchesRegularExpression( + '/<[^<>]*class="responsehistoryheader[^\"<>]*"[^<>]*>/', + $html, + 'Answer history not found' + ); + } + + /** + * Tests generation of a full page report with all sections + * + * @covers \quiz_archiver\Report::generate_full_page + * @covers \quiz_archiver\Report::convert_image_to_base64 + * @covers \quiz_archiver\Report::ensure_absolute_url + * + * @return void + * @throws \DOMException + * @throws \coding_exception + * @throws \dml_exception + * @throws \moodle_exception + * @throws \restore_controller_exception + */ + public function test_generate_full_page_stub(): void { + $this->resetAfterTest(); + $rc = $this->getDataGenerator()->import_reference_course(); + $report = new Report($rc->course, $rc->cm, $rc->quiz); + $html = $report->generate_full_page( + $rc->attemptids[0], + self::get_all_report_sections_enabled(), + false, // We need to disable this since $OUTPUT->header() is not working during tests. + false, // We need to disable this since $OUTPUT->header() is not working during tests. + true + ); + $this->assertNotEmpty($html, 'Generated report is empty'); + } + + /** + * Tests generation of a report with no header + * + * @covers \quiz_archiver\Report::generate + * + * @throws \restore_controller_exception + * @throws \coding_exception + * @throws \dml_exception + * @throws \moodle_exception + */ + public function test_generate_report_no_header(): void { + $this->resetAfterTest(); + $rc = $this->getDataGenerator()->import_reference_course(); + + // Generate report without a header. + $report = new Report($rc->course, $rc->cm, $rc->quiz); + $sections = self::get_all_report_sections_enabled(); + $sections['header'] = false; + $html = $report->generate($rc->attemptids[0], $sections); + $this->assertNotEmpty($html, 'Generated report is empty'); + + // Verify that quiz header is absent. + $this->assertDoesNotMatchRegularExpression( + '/]*quizreviewsummary[^<>]*>/', + $html, + 'Quiz header table found when it should be absent' + ); + + // If the quiz header is disabled, the quiz feedback should also be absent. + $this->assertDoesNotMatchRegularExpression( + '/]*>\s*'.preg_quote(get_string('feedback', 'quiz'), '/').'\s*<\/th>/', + $html, + 'Overall feedback header found when it should be absent' + ); + } + + /** + * Tests generation of a report with no quiz feedback + * + * @covers \quiz_archiver\Report::generate + * + * @return void + * @throws \coding_exception + * @throws \dml_exception + * @throws \moodle_exception + * @throws \restore_controller_exception + */ + public function test_generate_report_no_quiz_feedback(): void { + $this->resetAfterTest(); + $rc = $this->getDataGenerator()->import_reference_course(); + + // Generate report without quiz feedback. + $report = new Report($rc->course, $rc->cm, $rc->quiz); + $sections = self::get_all_report_sections_enabled(); + $sections['quiz_feedback'] = false; + $sections['questions'] = false; + $html = $report->generate($rc->attemptids[0], $sections); + $this->assertNotEmpty($html, 'Generated report is empty'); + + // Verify that quiz feedback is absent. + $this->assertMatchesRegularExpression( + '/]*quizreviewsummary[^<>]*>/', + $html, + 'Quiz header table not found' + ); + $this->assertDoesNotMatchRegularExpression( + '/]*>\s*'.preg_quote(get_string('feedback', 'quiz'), '/').'\s*<\/th>/', + $html, + 'Overall feedback header found when it should be absent' + ); + } + + /** + * Tests generation of a report with no questions + * + * @covers \quiz_archiver\Report::generate + * + * @return void + * @throws \coding_exception + * @throws \dml_exception + * @throws \moodle_exception + * @throws \restore_controller_exception + */ + public function test_generate_report_no_questions(): void { + $this->resetAfterTest(); + $rc = $this->getDataGenerator()->import_reference_course(); + + // Generate report without questions. + $report = new Report($rc->course, $rc->cm, $rc->quiz); + $sections = self::get_all_report_sections_enabled(); + $sections['question'] = false; + $html = $report->generate($rc->attemptids[0], $sections); + $this->assertNotEmpty($html, 'Generated report is empty'); + + // Verify that no questions are present. + $this->assertDoesNotMatchRegularExpression( + '/<[^<>]*class="[^\"<>]*que[^<>]*>/', + $html, + 'Question found when it should be absent' + ); + + // If questions are disabled, question_feedback, general_feedback, rightanswer and history should be absent. + $this->assertDoesNotMatchRegularExpression( + '/
    /', + $html, + 'Individual question feedback found when it should be absent' + ); + $this->assertDoesNotMatchRegularExpression( + '/
    /', + $html, + 'General question feedback found when it should be absent' + ); + $this->assertDoesNotMatchRegularExpression( + '/
    /', + $html, + 'Correct question answers found when they should be absent' + ); + $this->assertDoesNotMatchRegularExpression( + '/<[^<>]*class="responsehistoryheader[^\"<>]*"[^<>]*>/', + $html, + 'Answer history found when it should be absent' + ); + } + + /** + * Tests generation of a report with no individual question feedback + * + * @covers \quiz_archiver\Report::generate + * + * @return void + * @throws \coding_exception + * @throws \dml_exception + * @throws \moodle_exception + * @throws \restore_controller_exception + */ + public function test_generate_report_no_question_feedback(): void { + $this->resetAfterTest(); + $rc = $this->getDataGenerator()->import_reference_course(); + + // Generate report without question feedback. + $report = new Report($rc->course, $rc->cm, $rc->quiz); + $sections = self::get_all_report_sections_enabled(); + $sections['question_feedback'] = false; + $html = $report->generate($rc->attemptids[0], $sections); + $this->assertNotEmpty($html, 'Generated report is empty'); + + // Verify that question feedback is absent. + $this->assertDoesNotMatchRegularExpression( + '/
    /', + $html, + 'Individual question feedback found when it should be absent' + ); + } + + /** + * Tests generation of a report with no general question feedback + * + * @covers \quiz_archiver\Report::generate + * + * @return void + * @throws \coding_exception + * @throws \dml_exception + * @throws \moodle_exception + * @throws \restore_controller_exception + */ + public function test_generate_report_no_general_feedback(): void { + $this->resetAfterTest(); + $rc = $this->getDataGenerator()->import_reference_course(); + + // Generate report without general feedback. + $report = new Report($rc->course, $rc->cm, $rc->quiz); + $sections = self::get_all_report_sections_enabled(); + $sections['general_feedback'] = false; + $html = $report->generate($rc->attemptids[0], $sections); + $this->assertNotEmpty($html, 'Generated report is empty'); + + // Verify that general feedback is absent. + $this->assertDoesNotMatchRegularExpression( + '/
    /', + $html, + 'General question feedback found when it should be absent' + ); + } + + /** + * Tests generation of a report without showing correct answers for questions + * + * @covers \quiz_archiver\Report::generate + * + * @return void + * @throws \coding_exception + * @throws \dml_exception + * @throws \moodle_exception + * @throws \restore_controller_exception + */ + public function test_generate_report_no_rightanswers(): void { + $this->resetAfterTest(); + $rc = $this->getDataGenerator()->import_reference_course(); + + // Generate report without right answers. + $report = new Report($rc->course, $rc->cm, $rc->quiz); + $sections = self::get_all_report_sections_enabled(); + $sections['rightanswer'] = false; + $html = $report->generate($rc->attemptids[0], $sections); + $this->assertNotEmpty($html, 'Generated report is empty'); + + // Verify that right answers are absent. + $this->assertDoesNotMatchRegularExpression( + '/
    /', + $html, + 'Correct question answers found when they should be absent' + ); + } + + /** + * Tests generation of a report without showing answer histories + * + * @covers \quiz_archiver\Report::generate + * + * @return void + * @throws \coding_exception + * @throws \dml_exception + * @throws \moodle_exception + * @throws \restore_controller_exception + */ + public function test_generate_report_no_history(): void { + $this->resetAfterTest(); + $rc = $this->getDataGenerator()->import_reference_course(); + + // Generate report without answer history. + $report = new Report($rc->course, $rc->cm, $rc->quiz); + $sections = self::get_all_report_sections_enabled(); + $sections['history'] = false; + $html = $report->generate($rc->attemptids[0], $sections); + $this->assertNotEmpty($html, 'Generated report is empty'); + + // Verify that answer history is absent. + $this->assertDoesNotMatchRegularExpression( + '/<[^<>]*class="responsehistoryheader[^\"<>]*"[^<>]*>/', + $html, + 'Answer history found when it should be absent' + ); + } + + /** + * Tests to get the attachments of an attempt + * + * @covers \quiz_archiver\Report::get_attempt_attachments + * + * @return void + * @throws \dml_exception + * @throws \moodle_exception + * @throws \restore_controller_exception + */ + public function test_get_attempt_attachments(): void { + $this->resetAfterTest(); + $rc = $this->getDataGenerator()->import_reference_course(); + $report = new Report($rc->course, $rc->cm, $rc->quiz); + $attachments = $report->get_attempt_attachments($rc->attemptids[0]); + $this->assertNotEmpty($attachments, 'No attachments found'); + + // Find cake.md attachment. + $this->assertNotEmpty( + array_filter( + $attachments, + fn($a) => $a['file']->get_filename() === 'cake.md' + ), + 'cake.md attachment not found' + ); + } + + /** + * Tests metadata retrieval for attempt attachments + * + * @covers \quiz_archiver\Report::get_attempt_attachments_metadata + * + * @return void + * @throws \dml_exception + * @throws \moodle_exception + * @throws \restore_controller_exception + */ + public function test_get_attempt_attachments_metadata(): void { + $this->resetAfterTest(); + $rc = $this->getDataGenerator()->import_reference_course(); + $report = new Report($rc->course, $rc->cm, $rc->quiz); + $attachments = $report->get_attempt_attachments_metadata($rc->attemptids[0]); + $this->assertNotEmpty($attachments, 'No attachments found'); + + // Find cake.md attachment. + $cake = array_values(array_filter($attachments, fn($a) => $a->filename === 'cake.md'))[0]; + $this->assertNotEmpty($cake, 'cake.md attachment not found'); + + $this->assertNotEmpty($cake->slot, 'Attachment slot not set'); + $this->assertNotEmpty($cake->filename, 'Attachment filename not set'); + $this->assertNotEmpty($cake->filesize, 'Attachment filesize not set'); + $this->assertNotEmpty($cake->mimetype, 'Attachment mimetype not set'); + $this->assertNotEmpty($cake->contenthash, 'Attachment contenthash not set'); + $this->assertNotEmpty($cake->downloadurl, 'Attachment downloadurl not set'); + + $this->assertEquals( + sha1_file(__DIR__ . '/fixtures/cake.md'), + $cake->contenthash, + 'Attachment contenthash (SHA1) does not match' + ); + } + + /** + * Tests to get the attempts of a quiz + * + * @covers \quiz_archiver\Report::get_attempts + * + * @return void + * @throws \dml_exception + * @throws \moodle_exception + * @throws \restore_controller_exception + */ + public function test_get_attempts(): void { + $this->resetAfterTest(); + $rc = $this->getDataGenerator()->import_reference_course(); + + $report = new Report($rc->course, $rc->cm, $rc->quiz); + $attempts = $report->get_attempts(); + + $this->assertNotEmpty($attempts, 'No attempts found'); + $this->assertCount(count($rc->attemptids), $attempts, 'Incorrect number of attempts found'); + } + + /** + * Tests to get the attempt metadata array for a quiz + * + * @covers \quiz_archiver\Report::get_attempts_metadata + * + * @return void + * @throws \dml_exception + * @throws \moodle_exception + * @throws \restore_controller_exception + */ + public function test_get_attempts_metadata(): void { + $this->resetAfterTest(); + $rc = $this->getDataGenerator()->import_reference_course(); + $report = new Report($rc->course, $rc->cm, $rc->quiz); + + // Test without filters. + $attempts = $report->get_attempts_metadata(); + $this->assertNotEmpty($attempts, 'No attempts found without filters set'); + $this->assertCount(count($rc->attemptids), $attempts, 'Incorrect number of attempts found without filters set'); + + $attempt = array_shift($attempts); + $this->assertNotEmpty($attempt->attemptid, 'Attempt metadata does not contain attemptid'); + $this->assertNotEmpty($attempt->userid, 'Attempt metadata does not contain userid'); + $this->assertNotEmpty($attempt->attempt, 'Attempt metadata does not contain attempt'); + $this->assertNotEmpty($attempt->state, 'Attempt metadata does not contain state'); + $this->assertNotEmpty($attempt->timestart, 'Attempt metadata does not contain timestart'); + $this->assertNotEmpty($attempt->timefinish, 'Attempt metadata does not contain timefinish'); + $this->assertNotEmpty($attempt->username, 'Attempt metadata does not contain username'); + $this->assertNotEmpty($attempt->firstname, 'Attempt metadata does not contain firstname'); + $this->assertNotEmpty($attempt->lastname, 'Attempt metadata does not contain lastname'); + $this->assertNotNull($attempt->idnumber, 'Attempt metadata does not contain idnumber'); // ID number can be empty. + + // Test filtered. + $attemptsfilteredexisting = $report->get_attempts_metadata($rc->attemptids); + $this->assertNotEmpty($attemptsfilteredexisting, 'No attempts found with existing attempt ids'); + $this->assertCount( + count($rc->attemptids), + $attemptsfilteredexisting, + 'Incorrect number of attempts found with existing attempt ids' + ); + + $attemptsfilterednonexisting = $report->get_attempts_metadata([-1, -2, -3]); + $this->assertEmpty($attemptsfilterednonexisting, 'Attempts found for non-existing attempt ids'); + } + + /** + * Tests to get the IDs of users with attempts in a quiz + * + * @covers \quiz_archiver\Report::get_users_with_attempts + * + * @return void + * @throws \dml_exception + * @throws \moodle_exception + * @throws \restore_controller_exception + */ + public function test_get_users_with_attempts(): void { + $this->resetAfterTest(); + $rc = $this->getDataGenerator()->import_reference_course(); + $report = new Report($rc->course, $rc->cm, $rc->quiz); + + $users = $report->get_users_with_attempts(); + $this->assertNotEmpty($users, 'No users found with attempts'); + $this->assertEquals(array_values($rc->userids), array_values($users), 'Incorrect IDs found for users with attempts'); + } + + /** + * Tests to retrieve the latest attemptid of a user + * + * @covers \quiz_archiver\Report::get_latest_attempt_for_user + * + * @return void + * @throws \dml_exception + * @throws \moodle_exception + * @throws \restore_controller_exception + */ + public function test_get_latest_attempt_for_user(): void { + $this->resetAfterTest(); + $rc = $this->getDataGenerator()->import_reference_course(); + $report = new Report($rc->course, $rc->cm, $rc->quiz); + + $latestattempt = $report->get_latest_attempt_for_user($rc->userids[0]); + $this->assertNotEmpty($latestattempt, 'No latest attempt found for user'); + + $latestattemptmissing = $report->get_latest_attempt_for_user(-1); + $this->assertEmpty($latestattemptmissing, 'Latest attempt found for non-existing user'); + } + + /** + * Tests to retrieve existing and nonexisting attempts + * + * @covers \quiz_archiver\Report::attempt_exists + * + * @return void + * @throws \dml_exception + * @throws \moodle_exception + * @throws \restore_controller_exception + */ + public function test_attempt_exists(): void { + $this->resetAfterTest(); + $rc = $this->getDataGenerator()->import_reference_course(); + $report = new Report($rc->course, $rc->cm, $rc->quiz); + + $this->assertTrue($report->attempt_exists($rc->attemptids[0]), 'Existing attempt not found'); + $this->assertFalse($report->attempt_exists(-1), 'Non-existing attempt found'); + } + + /** + * Tests conversion/sanitization of formdata to report section settings + * + * @covers \quiz_archiver\Report::build_report_sections_from_formdata + * + * @return void + */ + public function test_build_report_sections_from_formdata(): void { + // Test all sections enabled. + $formdata = self::get_formdata_all_reports_sections_enabled(); + $sections = Report::build_report_sections_from_formdata($formdata); + $this->assertEquals( + self::get_all_report_sections_enabled(), + $sections, + 'Full formdata not correctly converted to report sections' + ); + + // Test removal of dependent sections. + $formdata = self::get_formdata_all_reports_sections_enabled(); + $formdata->export_report_section_question = 0; + $sections = Report::build_report_sections_from_formdata($formdata); + $this->assertEmpty($sections['question'], 'Root section not removed correctly'); + $this->assertEmpty($sections['question_feedback'], 'Dependent section question_feedback not removed correctly'); + $this->assertEmpty($sections['general_feedback'], 'Dependent section general_feedback not removed correctly'); + $this->assertEmpty($sections['rightanswer'], 'Dependent section rightanswer not removed correctly'); + $this->assertEmpty($sections['history'], 'Dependent section history not removed correctly'); + $this->assertEmpty($sections['attachments'], 'Dependent section attachments not removed correctly'); + + // Test removal of superfluous sections. + $formdata = self::get_formdata_all_reports_sections_enabled(); + $formdata->export_report_section_superfluous = 1; + $sections = Report::build_report_sections_from_formdata($formdata); + $this->assertEquals(self::get_all_report_sections_enabled(), $sections, 'Superfluous section not removed correctly'); + } + +} diff --git a/tests/timestampprotocolclient_test.php b/tests/timestampprotocolclient_test.php new file mode 100644 index 0000000..52e92ac --- /dev/null +++ b/tests/timestampprotocolclient_test.php @@ -0,0 +1,101 @@ +. + +/** + * Tests for the TimeStampProtocolClient class + * + * @package quiz_archiver + * @copyright 2024 Niels Gandraß + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace quiz_archiver; + +/** + * Tests for the TimeStampProtocolClient class + */ +final class timestampprotocolclient_test extends \advanced_testcase { + + /** + * Tests the creation of a TimeStampProtocolClient instance + * + * @covers \quiz_archiver\TimeStampProtocolClient::__construct + * @covers \quiz_archiver\TimeStampProtocolClient::get_serverurl + * + * @return void + */ + public function test_creation(): void { + $client = new TimeStampProtocolClient('http://localhost:12345'); + $this->assertInstanceOf(TimeStampProtocolClient::class, $client); + $this->assertEquals('http://localhost:12345', $client->get_serverurl()); + } + + /** + * Tests the generation of a nonce + * + * @covers \quiz_archiver\TimeStampProtocolClient::generate_nonce + * + * @return void + * @throws \Exception + */ + public function test_generate_nonce(): void { + $nonce = TimeStampProtocolClient::generate_nonce(); + $this->assertNotEmpty($nonce, 'Nonce is empty'); + $this->assertSame(16, strlen($nonce), 'Nonce length is not 16 bytes'); + + for ($i = 0; $i < 100; $i++) { + $this->assertNotEquals( + $nonce, + TimeStampProtocolClient::generate_nonce(), + 'Repeated calls to generate_nonce() return the same nonce' + ); + } + } + + /** + * Tests the generation of a TSP request from valid data + * + * @covers \quiz_archiver\TimeStampProtocolClient::sign + * @covers \quiz_archiver\TimeStampProtocolClient::create_timestamp_request + * + * @return void + * @throws \coding_exception + */ + public function test_signing_valid_data(): void { + $client = new TimeStampProtocolClient('http://localhost:12345'); + + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/'.get_string('tsp_client_error_curl', 'quiz_archiver', '').'/'); + $client->sign('6e82908cfa04dbf1706aa938e32f27e6a1d5f096df5c472795a93f8ab9de4c72'); + } + + /** + * Test the generation of a TSP request from invalid data + * + * @covers \quiz_archiver\TimeStampProtocolClient::sign + * + * @return void + * @throws \Exception + */ + public function test_signing_invalid_data(): void { + $client = new TimeStampProtocolClient('http://localhost:12345'); + + $this->expectException(\ValueError::class); + $this->expectExceptionMessageMatches('/Invalid hexadecimal SHA256 hash/'); + $client->sign('invalid-data'); + } + +} diff --git a/tests/classes/TSPManager_test.php b/tests/tspmanager_test.php similarity index 52% rename from tests/classes/TSPManager_test.php rename to tests/tspmanager_test.php index 5c5d76c..83d9976 100644 --- a/tests/classes/TSPManager_test.php +++ b/tests/tspmanager_test.php @@ -24,111 +24,70 @@ namespace quiz_archiver; - use context_course; /** * Tests for the TSPManager class */ -class TSPManager_test extends \advanced_testcase { +final class tspmanager_test extends \advanced_testcase { /** - * Generates a mock quiz to use in the tests + * Returns the data generator for the quiz_archiver plugin * - * @return \stdClass Created mock objects + * @return \quiz_archiver_generator The data generator for the quiz_archiver plugin */ - protected function generateMockQuiz(): \stdClass { - // Create course, course module and quiz - $this->resetAfterTest(); - - // Prepare user and course - $user = $this->getDataGenerator()->create_user(); - $course = $this->getDataGenerator()->create_course(); - $quiz = $this->getDataGenerator()->create_module('quiz', [ - 'course' => $course->id, - 'grade' => 100.0, - 'sumgrades' => 100 - ]); - - return (object) [ - 'user' => $user, - 'course' => $course, - 'quiz' => $quiz, - ]; - } - - /** - * Generates a dummy artifact file, stored in the context of the given course. - * - * @param int $courseid ID of the course to store the file in - * @param int $cmid ID of the course module to store the file in - * @param int $quizid ID of the quiz to store the file in - * @param string $filename Name of the file to create - * @return \stored_file The created file handle - * @throws \file_exception - * @throws \stored_file_creation_exception - */ - protected function generateArtifactFile(int $courseid, int $cmid, int $quizid, string $filename): \stored_file { - $this->resetAfterTest(); - $ctx = context_course::instance($courseid); - - return get_file_storage()->create_file_from_string( - [ - 'contextid' => $ctx->id, - 'component' => FileManager::COMPONENT_NAME, - 'filearea' => FileManager::ARTIFACTS_FILEAREA_NAME, - 'itemid' => 0, - 'filepath' => "/{$courseid}/{$cmid}/{$quizid}/", - 'filename' => $filename, - 'timecreated' => time(), - 'timemodified' => time(), - ], - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.' - ); + // @codingStandardsIgnoreLine + public static function getDataGenerator(): \quiz_archiver_generator { + return parent::getDataGenerator()->get_plugin_generator('quiz_archiver'); } /** * Creates a TSPManager that uses a mocked TimeStampProtocolClient * * @param ArchiveJob $job Job to create the TSPManager for - * @param string $dummy_server Dummy TSP server URL - * @param string $dummy_query Dummy TSP query data - * @param string $dummy_reply Dummy TSP reply data + * @param string $dummyserver Dummy TSP server URL + * @param string $dummyquery Dummy TSP query data + * @param string $dummyreply Dummy TSP reply data * @return TSPManager */ - protected function createMockTSPManager( + protected function create_mock_tspmanager( ArchiveJob $job, - string $dummy_server = 'localhost', - string $dummy_query = 'tsp-dummy-query', - string $dummy_reply = 'tsp-dummy-reply-0123456789abcdef' + string $dummyserver = 'localhost', + string $dummyquery = 'tsp-dummy-query', + string $dummyreply = 'tsp-dummy-reply-0123456789abcdef' ): TSPManager { - // Prepare TimeStampProtocolClient that returns dummy data - $tspClientMock = $this->getMockBuilder(TimeStampProtocolClient::class) + // Prepare TimeStampProtocolClient that returns dummy data. + $tspclientmock = $this->getMockBuilder(TimeStampProtocolClient::class) ->onlyMethods(['sign']) - ->setConstructorArgs([$dummy_server]) + ->setConstructorArgs([$dummyserver]) ->getMock(); - $tspClientMock->expects($this->any()) + $tspclientmock->expects($this->any()) ->method('sign') ->willReturn([ - 'query' => $dummy_query, - 'reply' => $dummy_reply, + 'query' => $dummyquery, + 'reply' => $dummyreply, ]); - // Create TSPManager that uses the mocked TimeStampProtocolClient - $tspManager = $this->getMockBuilder(TSPManager::class) - ->onlyMethods(['getTimestampProtocolClient']) + // Create TSPManager that uses the mocked TimeStampProtocolClient. + $tspmanager = $this->getMockBuilder(TSPManager::class) + ->onlyMethods(['get_timestampprotocolclient']) ->setConstructorArgs([$job]) ->getMock(); - $tspManager->expects($this->any()) - ->method('getTimestampProtocolClient') - ->willReturn($tspClientMock); + $tspmanager->expects($this->any()) + ->method('get_timestampprotocolclient') + ->willReturn($tspclientmock); - return $tspManager; + return $tspmanager; } /** * Tests signing of valid artifacts using TSP * + * @covers \quiz_archiver\TSPManager::timestamp + * @covers \quiz_archiver\TSPManager::has_tsp_timestamp + * @covers \quiz_archiver\TSPManager::wants_tsp_timestamp + * @covers \quiz_archiver\TSPManager::get_tsp_data + * * @return void * @throws \dml_exception * @throws \file_exception @@ -136,12 +95,13 @@ protected function createMockTSPManager( * @throws \stored_file_creation_exception */ public function test_tsp_timestamp(): void { - // Prepare plugin settings + // Prepare plugin settings. set_config('tsp_server_url', 'localhost', 'quiz_archiver'); set_config('tsp_enable', true, 'quiz_archiver'); - // Generate job with artifact - $mocks = $this->generateMockQuiz(); + // Generate job with artifact. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); $job = ArchiveJob::create( '10000000-1234-5678-abcd-ef4242424242', $mocks->course->id, @@ -155,27 +115,55 @@ public function test_tsp_timestamp(): void { ArchiveJob::STATUS_FINISHED ); - $artifact = $this->generateArtifactFile($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id, 'test.tar.gz'); + $artifact = $this->getDataGenerator()->create_artifact_file( + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + 'test.tar.gz' + ); $sha256dummy = hash('sha256', 'foo bar baz'); $job->link_artifact($artifact->get_id(), $sha256dummy); - // Ensure that artifact was not yet signed - $this->assertFalse($job->TSPManager()->has_tsp_timestamp(), 'Artifact was detected as signed without it being signed'); + // Ensure that artifact was not yet signed. + $this->assertFalse($job->tspmanager()->has_tsp_timestamp(), 'Artifact was detected as signed without it being signed'); + + // Ensure that the artifact wants to be signed. + $this->assertTrue($job->tspmanager()->wants_tsp_timestamp(), 'Artifact was not detected as wanting to be signed'); - // Try signing the artifact using TSP - $tspManager = $this->createMockTSPManager($job); - $tspManager->timestamp(); - $this->assertTrue($tspManager->has_tsp_timestamp(), 'Artifact was not detected as signed after signing it'); + // Try signing the artifact using TSP. + $tspmanager = $this->create_mock_tspmanager($job); + $tspmanager->timestamp(); + $this->assertTrue($tspmanager->has_tsp_timestamp(), 'Artifact was not detected as signed after signing it'); - // Ensure that the TSP data was stored correctly - $this->assertEquals('tsp-dummy-query', $tspManager->get_tsp_data()->query, 'TSP query was not stored correctly'); - $this->assertEquals('tsp-dummy-reply-0123456789abcdef', $tspManager->get_tsp_data()->reply, 'TSP reply was not stored correctly'); - $this->assertEquals('localhost', $tspManager->get_tsp_data()->server, 'TSP server URL was not stored correctly'); + // Ensure that the TSP data was stored correctly. + $this->assertEquals( + 'tsp-dummy-query', + $tspmanager->get_tsp_data()->query, + 'TSP query was not stored correctly' + ); + $this->assertEquals( + 'tsp-dummy-reply-0123456789abcdef', + $tspmanager->get_tsp_data()->reply, + 'TSP reply was not stored correctly' + ); + $this->assertEquals( + 'localhost', + $tspmanager->get_tsp_data()->server, + 'TSP server URL was not stored correctly' + ); + + // Ensure that the artifact does not want to be signed again. + $this->assertFalse( + $job->tspmanager()->wants_tsp_timestamp(), + 'Artifact was detected as wanting to be signed after it was signed' + ); } /** * Tests deletion of TSP data * + * @covers \quiz_archiver\TSPManager::delete_tsp_data + * * @return void * @throws \dml_exception * @throws \file_exception @@ -183,12 +171,13 @@ public function test_tsp_timestamp(): void { * @throws \stored_file_creation_exception */ public function test_delete_tsp_data(): void { - // Prepare plugin settings + // Prepare plugin settings. set_config('tsp_server_url', 'localhost', 'quiz_archiver'); set_config('tsp_enable', true, 'quiz_archiver'); - // Generate job with artifact - $mocks = $this->generateMockQuiz(); + // Generate job with artifact. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); $job = ArchiveJob::create( '20000000-1234-5678-abcd-ef4242424242', $mocks->course->id, @@ -202,35 +191,43 @@ public function test_delete_tsp_data(): void { ArchiveJob::STATUS_FINISHED ); - $artifact = $this->generateArtifactFile($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id, 'test.tar.gz'); + $artifact = $this->getDataGenerator()->create_artifact_file( + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + 'test.tar.gz' + ); $sha256dummy = hash('sha256', 'foo bar baz'); $job->link_artifact($artifact->get_id(), $sha256dummy); - // Sign the artifact using TSP - $tspManager = $this->createMockTSPManager($job); - $tspManager->timestamp(); - $this->assertTrue($tspManager->has_tsp_timestamp(), 'Artifact was not detected as signed after signing it'); + // Sign the artifact using TSP. + $tspmanager = $this->create_mock_tspmanager($job); + $tspmanager->timestamp(); + $this->assertTrue($tspmanager->has_tsp_timestamp(), 'Artifact was not detected as signed after signing it'); - // Delete the TSP data - $tspManager->delete_tsp_data(); - $this->assertFalse($tspManager->has_tsp_timestamp(), 'Artifact was detected as signed after deleting the TSP data'); + // Delete the TSP data. + $tspmanager->delete_tsp_data(); + $this->assertFalse($tspmanager->has_tsp_timestamp(), 'Artifact was detected as signed after deleting the TSP data'); } /** * Tests error handling when trying to sign non-existing artifacts * + * @covers \quiz_archiver\TSPManager::timestamp + * * @return void * @throws \coding_exception * @throws \dml_exception * @throws \moodle_exception */ public function test_signing_invalid_artifact(): void { - // Prepare plugin settings + // Prepare plugin settings. set_config('tsp_server_url', 'localhost', 'quiz_archiver'); set_config('tsp_enable', true, 'quiz_archiver'); - // Generate job without artifact - $mocks = $this->generateMockQuiz(); + // Generate job without artifact. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); $job = ArchiveJob::create( '30000000-1234-5678-abcd-ef4242424242', $mocks->course->id, @@ -244,15 +241,18 @@ public function test_signing_invalid_artifact(): void { ArchiveJob::STATUS_FINISHED ); - // Try signing the artifact using TSP + // Try signing the artifact using TSP. $this->expectException(\RuntimeException::class); $this->expectExceptionMessage(get_string('archive_signing_failed_no_artifact', 'quiz_archiver')); - $this->createMockTSPManager($job)->timestamp(); + $this->create_mock_tspmanager($job)->timestamp(); } /** * Tests error handling when trying to sign an artifact while TSP is globally disabled * + * @covers \quiz_archiver\TSPManager::timestamp + * @covers \quiz_archiver\TSPManager::wants_tsp_timestamp + * * @return void * @throws \coding_exception * @throws \dml_exception @@ -261,11 +261,12 @@ public function test_signing_invalid_artifact(): void { * @throws \stored_file_creation_exception */ public function test_signing_disabled(): void { - // Ensure signing is disabled + // Ensure signing is disabled. set_config('tsp_enable', false, 'quiz_archiver'); - // Generate job with artifact - $mocks = $this->generateMockQuiz(); + // Generate job with artifact. + $this->resetAfterTest(); + $mocks = $this->getDataGenerator()->create_mock_quiz(); $job = ArchiveJob::create( '40000000-1234-5678-abcd-ef4242424242', $mocks->course->id, @@ -279,14 +280,25 @@ public function test_signing_disabled(): void { ArchiveJob::STATUS_FINISHED ); - $artifact = $this->generateArtifactFile($mocks->course->id, $mocks->quiz->cmid, $mocks->quiz->id, 'test.tar.gz'); + $artifact = $this->getDataGenerator()->create_artifact_file( + $mocks->course->id, + $mocks->quiz->cmid, + $mocks->quiz->id, + 'test.tar.gz' + ); $sha256sum = hash('sha256', 'foo bar baz'); $job->link_artifact($artifact->get_id(), $sha256sum); - // Try signing the artifact using TSP + // Check that the artifact does not want to be signed. + $this->assertFalse( + $job->tspmanager()->wants_tsp_timestamp(), + 'Artifact was detected as wanting to be signed while TSP is disabled' + ); + + // Try signing the artifact using TSP. $this->expectException(\Exception::class); $this->expectExceptionMessage(get_string('archive_signing_failed_tsp_disabled', 'quiz_archiver')); - $this->createMockTSPManager($job)->timestamp(); + $this->create_mock_tspmanager($job)->timestamp(); } -} \ No newline at end of file +} diff --git a/version.php b/version.php index 4a65d25..bef0818 100644 --- a/version.php +++ b/version.php @@ -22,12 +22,11 @@ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -defined('MOODLE_INTERNAL') || die(); +defined('MOODLE_INTERNAL') || die(); // @codeCoverageIgnore $plugin->component = 'quiz_archiver'; -$plugin->release = '1.2.4'; -$plugin->version = 2024021901; +$plugin->release = '2.2.0'; +$plugin->version = 2024102900; $plugin->requires = 2022112800; -$plugin->supported = [401, 403]; -//$plugin->incompatible = 402; +$plugin->supported = [401, 405]; $plugin->maturity = MATURITY_STABLE;
    {{#str}} status {{/str}} - + {{status_display_args.text}} + {{#status_display_args.statusextras.progress}} + +  –  {{#str}} progress, quiz_archiver {{/str}}: + {{status_display_args.statusextras.progress}}% + + {{/status_display_args.statusextras.progress}}