diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index c68765b..be38375 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1 @@ -github: :vendor_name +github: stats4sd diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 96701be..2feb539 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,11 +1,11 @@ blank_issues_enabled: false contact_links: - name: Ask a question - url: https://github.com/:vendor_name/:package_name/discussions/new?category=q-a + url: https://github.com/stats4sd/filament-odk-link/discussions/new?category=q-a about: Ask the community for help - name: Request a feature - url: https://github.com/:vendor_name/:package_name/discussions/new?category=ideas + url: https://github.com/stats4sd/filament-odk-link/discussions/new?category=ideas about: Share ideas for new features - name: Report a security issue - url: https://github.com/:vendor_name/:package_name/security/policy + url: https://github.com/stats4sd/filament-odk-link/security/policy about: Learn how to notify us for sensitive bugs diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 12ab7c2..2869225 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -1,3 +1,3 @@ # Security Policy -If you discover any security related issues, please email author@domain.com instead of using the issue tracker. +If you discover any security related issues, please email support@stats4sd.org instead of using the issue tracker. diff --git a/CHANGELOG.md b/CHANGELOG.md index 767365d..fb561fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -All notable changes to `:package_name` will be documented in this file. +All notable changes to `filament-odk-link` will be documented in this file. ## 1.0.0 - 202X-XX-XX diff --git a/LICENSE.md b/LICENSE.md index 58c9ad4..ede279a 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) :vendor_name +Copyright (c) stats4sd Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 02d882d..b3d8b9a 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,39 @@ -# :package_description +# Manage ODK forms in a harmonised way with Laravel Filament -[![Latest Version on Packagist](https://img.shields.io/packagist/v/:vendor_slug/:package_slug.svg?style=flat-square)](https://packagist.org/packages/:vendor_slug/:package_slug) -[![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/:vendor_slug/:package_slug/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/:vendor_slug/:package_slug/actions?query=workflow%3Arun-tests+branch%3Amain) -[![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/:vendor_slug/:package_slug/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/:vendor_slug/:package_slug/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) -[![Total Downloads](https://img.shields.io/packagist/dt/:vendor_slug/:package_slug.svg?style=flat-square)](https://packagist.org/packages/:vendor_slug/:package_slug) +[![Latest Version on Packagist](https://img.shields.io/packagist/v/stats4sd/filament-odk-link.svg?style=flat-square)](https://packagist.org/packages/stats4sd/filament-odk-link) +[![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/stats4sd/filament-odk-link/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/stats4sd/filament-odk-link/actions?query=workflow%3Arun-tests+branch%3Amain) +[![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/stats4sd/filament-odk-link/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/stats4sd/filament-odk-link/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) +[![Total Downloads](https://img.shields.io/packagist/dt/stats4sd/filament-odk-link.svg?style=flat-square)](https://packagist.org/packages/stats4sd/filament-odk-link) - ---- -This repo can be used to scaffold a Filament plugin. Follow these steps to get started: -1. Press the "Use this template" button at the top of this repo to create a new repo with the contents of this skeleton. -2. Run "php ./configure.php" to run a script that will replace all placeholders throughout all the files. -3. Make something great! ---- - This is where your description should go. Limit it to a paragraph or two. Consider adding a small example. -## Installation +## Installationp You can install the package via composer: ```bash -composer require :vendor_slug/:package_slug +composer require stats4sd/filament-odk-link ``` You can publish and run the migrations with: ```bash -php artisan vendor:publish --tag=":package_slug-migrations" +php artisan vendor:publish --tag="filament-odk-link-migrations" php artisan migrate ``` You can publish the config file with: ```bash -php artisan vendor:publish --tag=":package_slug-config" +php artisan vendor:publish --tag="filament-odk-link-config" ``` Optionally, you can publish the views using ```bash -php artisan vendor:publish --tag=":package_slug-views" +php artisan vendor:publish --tag="filament-odk-link-views" ``` This is the contents of the published config file: @@ -54,8 +46,8 @@ return [ ## Usage ```php -$variable = new VendorName\Skeleton(); -echo $variable->echoPhrase('Hello, VendorName!'); +$filamentOdkLink = new Stats4sd\FilamentOdkLink(); +echo $filamentOdkLink->echoPhrase('Hello, Stats4sd!'); ``` ## Testing @@ -78,7 +70,7 @@ Please review [our security policy](../../security/policy) on how to report secu ## Credits -- [:author_name](https://github.com/:author_username) +- [Stats4SD](https://github.com/stats4sd) - [All Contributors](../../contributors) ## License diff --git a/bin/build.js b/bin/build.js index 913baf4..02c5d53 100644 --- a/bin/build.js +++ b/bin/build.js @@ -46,5 +46,5 @@ const defaultOptions = { compile({ ...defaultOptions, entryPoints: ['./resources/js/index.js'], - outfile: './resources/dist/skeleton.js', + outfile: './resources/dist/filament-odk-link.js', }) diff --git a/composer.json b/composer.json index 53946f2..91d6a0a 100644 --- a/composer.json +++ b/composer.json @@ -1,29 +1,27 @@ { - "name": ":vendor_slug/:package_slug", - "description": ":package_description", + "name": "stats4sd/filament-odk-link", + "description": "Manage ODK forms in a harmonised way with Laravel Filament ", "keywords": [ - ":vendor_name", + "stats4sd", "laravel", - ":package_slug" + "filament-odk-link" ], - "homepage": "https://github.com/:vendor_slug/:package_slug", + "homepage": "https://github.com/stats4sd/filament-odk-link", "support": { - "issues": "https://github.com/:vendor_slug/:package_slug/issues", - "source": "https://github.com/:vendor_slug/:package_slug" + "issues": "https://github.com/stats4sd/filament-odk-link/issues", + "source": "https://github.com/stats4sd/filament-odk-link" }, "license": "MIT", "authors": [ { - "name": ":author_name", - "email": "author@domain.com", + "name": "Stats4SD", + "email": "support@stats4sd.org", "role": "Developer" } ], "require": { "php": "^8.1", "filament/filament": "^3.0", - "filament/forms": "^3.0", - "filament/tables": "^3.0", "spatie/laravel-package-tools": "^1.15.0", "illuminate/contracts": "^10.0" }, @@ -37,18 +35,17 @@ "pestphp/pest-plugin-laravel": "^2.0", "phpstan/extension-installer": "^1.1", "phpstan/phpstan-deprecation-rules": "^1.0", - "phpstan/phpstan-phpunit": "^1.0", - "spatie/laravel-ray": "^1.26" + "phpstan/phpstan-phpunit": "^1.0" }, "autoload": { "psr-4": { - "VendorName\\Skeleton\\": "src/", - "VendorName\\Skeleton\\Database\\Factories\\": "database/factories/" + "Stats4sd\\FilamentOdkLink\\": "src/", + "Stats4sd\\FilamentOdkLink\\Database\\Factories\\": "database/factories/" } }, "autoload-dev": { "psr-4": { - "VendorName\\Skeleton\\Tests\\": "tests/" + "Stats4sd\\FilamentOdkLink\\Tests\\": "tests/" } }, "scripts": { @@ -68,13 +65,13 @@ "extra": { "laravel": { "providers": [ - "VendorName\\Skeleton\\SkeletonServiceProvider" + "Stats4sd\\FilamentOdkLink\\FilamentOdkLinkServiceProvider" ], "aliases": { - "Skeleton": "VendorName\\Skeleton\\Facades\\Skeleton" + "FilamentOdkLink": "Stats4sd\\FilamentOdkLink\\Facades\\FilamentOdkLink" } } }, "minimum-stability": "dev", "prefer-stable": true -} +} \ No newline at end of file diff --git a/config/odk-link.php b/config/odk-link.php new file mode 100644 index 0000000..20c5fff --- /dev/null +++ b/config/odk-link.php @@ -0,0 +1,68 @@ + [ + + /** + * Tells the system which Aggregation system is in use. Possible values are: + * - odk-central + */ + 'aggregator' => env('ODK_SERVICE', 'odk-central'), + + /** + * The base url for the service (without the trailing '/'). + * If you use the public Kobotoolbox, this will be + * - 'https://kf.kobotoolbox.org' or + * - 'https://kobo.humanitarianresponse.info' + * + * If you use a custom installation of ODK Central or Kobotoolbox, it will be the base url to your service. + * + * + */ + 'url' => env('ODK_URL', ''), + 'base_endpoint' => env('ODK_ENDPOINT', env('ODK_URL')."/v1"), + + /** + * Username and password for the main platform account + * The platform requires a 'primary' user account on the ODK Central / KoboToolbox server to manage deployments of ODK forms. + * This account will *own* every form published by the platform. + * + * We recommend not using an account that individuals typically use or have access to, to avoid mismatch between forms deployed and forms in the Laravel database. + */ + 'username' => env('ODK_USERNAME', ''), + 'password' => env('ODK_PASSWORD', ''), + + // the password to be used for individual project accounts + // TODO: consider options for allowing users to set their own passwords (which we cannot keep in plain text, so we must ask the user for it every time). + // TODO: consider how to hash this - maybe each project has a unique seed that combines with the main ODK_PASSWORD to generate this. + 'project-password' => env('ODK_PROJECT_PASSWORD', env('ODK_PASSWORD')), + ], + + 'storage' => [ + 'xlsforms' => config('filesystem.default', 'public'), + 'media' => config('filesystem.default', 'public'), + ], + + 'roles' => [ + // the role that a user must have in order to see *all* forms, and not just the ones owned by an entity linked to the user. + 'xlsform-admin' => env('XLSFORM_ADMIN_ROLE', 'admin'), + ], + + 'owners' => [ + 'main_type' => env('MAIN_OWNER_TYPE', 'team'), + ], + + 'submission' => [ + + // The class and method used to process the submissions. + // The method should be: + // - a public static function; + // - accept a OdkLink\Models\Submission object as the only required variable.; + 'process_method' => [ + 'class' => env('SUBMISSION_PROCESS_CLASS', null), + 'method' => env('SUBMISSION_PROCESS_METHOD', null), + ], + ], +]; diff --git a/config/skeleton.php b/config/skeleton.php deleted file mode 100644 index 7e74186..0000000 --- a/config/skeleton.php +++ /dev/null @@ -1,6 +0,0 @@ -" : '') . "\e[0m"); -writeln("Namespace : \e[0;36m$vendorNamespace\\$className\e[0m"); -writeln("Class name : \e[0;36m$className\e[0m"); -writeln('---'); -writeln("\e[1;37mPackages & Utilities\e[0m"); -writeln('Larastan/PhpStan : ' . ($usePhpStan ? "\e[0;32mYes" : "\e[0;31mNo") . "\e[0m"); -writeln('Pint : ' . ($usePint ? "\e[0;32mYes" : "\e[0;31mNo") . "\e[0m"); -writeln('Use Dependabot : ' . ($useDependabot ? "\e[0;32mYes" : "\e[0;31mNo") . "\e[0m"); -writeln('Use Ray : ' . ($useLaravelRay ? "\e[0;32mYes" : "\e[0;31mNo") . "\e[0m"); -writeln('Auto-Changelog : ' . ($useUpdateChangelogWorkflow ? "\e[0;32mYes" : "\e[0;31mNo") . "\e[0m"); -if ($formsOnly) { - writeln("Filament/Forms : \e[0;32mYes\e[0m"); -} elseif ($tablesOnly) { - writeln("Filament/Tables : \e[0;32mYes\e[0m"); -} else { - writeln("Filament/Filament : \e[0;32mYes\e[0m"); -} -writeln('------'); -writeln("\r"); -writeln('This script will replace the above values in all relevant files in the project directory.'); -writeln("\r"); - -if (! confirm('Modify files?', true)) { - exit(1); -} - -if ($formsOnly) { - safeUnlink(__DIR__ . '/src/SkeletonTheme.php'); - safeUnlink(__DIR__ . '/src/SkeletonPlugin.php'); - - removeComposerDeps([ - 'filament/filament', - 'filament/tables', - ], 'require'); -} elseif ($tablesOnly) { - safeUnlink(__DIR__ . '/src/SkeletonTheme.php'); - safeUnlink(__DIR__ . '/src/SkeletonPlugin.php'); - - removeComposerDeps([ - 'filament/filament', - 'filament/forms', - ], 'require'); -} else { - if ($isTheme) { - safeUnlink(__DIR__ . '/src/SkeletonServiceProvider.php'); - safeUnlink(__DIR__ . '/src/SkeletonPlugin.php'); - safeUnlink(__DIR__ . '/src/Skeleton.php'); - removeDirectory(__DIR__ . '/bin'); - removeDirectory(__DIR__ . '/config'); - removeDirectory(__DIR__ . '/database'); - removeDirectory(__DIR__ . '/stubs'); - removeDirectory(__DIR__ . '/resources/js'); - removeDirectory(__DIR__ . '/resources/lang'); - removeDirectory(__DIR__ . '/resources/views'); - removeDirectory(__DIR__ . '/src/Commands'); - removeDirectory(__DIR__ . '/src/Facades'); - removeDirectory(__DIR__ . '/src/Testing'); - - setupPackageJsonForTheme(); - - } else { - safeUnlink(__DIR__ . '/src/SkeletonTheme.php'); - } - - removeComposerDeps([ - 'filament/forms', - 'filament/tables', - ], 'require'); -} - -$files = (str_starts_with(strtoupper(PHP_OS), 'WIN') ? replaceForWindows() : replaceForAllOtherOSes()); - -foreach ($files as $file) { - replaceInFile($file, [ - ':author_name' => $authorName, - ':author_username' => $authorUsername, - 'author@domain.com' => $authorEmail, - ':vendor_name' => $vendorName, - ':vendor_slug' => $vendorSlug, - 'VendorName' => $vendorNamespace, - ':package_name' => $packageName, - ':package_slug' => $packageSlug, - ':package_slug_without_prefix' => $packageSlugWithoutPrefix, - 'Skeleton' => $className, - 'skeleton' => $packageSlug, - 'migration_table_name' => titleSnake($packageSlug), - 'variable' => $variableName, - ':package_description' => $description, - ]); - - match (true) { - str_contains($file, determineSeparator('src/Skeleton.php')) => rename($file, determineSeparator('./src/' . $className . '.php')), - str_contains($file, determineSeparator('src/SkeletonServiceProvider.php')) => rename($file, determineSeparator('./src/' . $className . 'ServiceProvider.php')), - str_contains($file, determineSeparator('src/SkeletonTheme.php')) => rename($file, determineSeparator('./src/' . $className . 'Theme.php')), - str_contains($file, determineSeparator('src/SkeletonPlugin.php')) => rename($file, determineSeparator('./src/' . $className . 'Plugin.php')), - str_contains($file, determineSeparator('src/Facades/Skeleton.php')) => rename($file, determineSeparator('./src/Facades/' . $className . '.php')), - str_contains($file, determineSeparator('src/Commands/SkeletonCommand.php')) => rename($file, determineSeparator('./src/Commands/' . $className . 'Command.php')), - str_contains($file, determineSeparator('src/Testing/TestsSkeleton.php')) => rename($file, determineSeparator('./src/Testing/Tests' . $className . '.php')), - str_contains($file, determineSeparator('database/migrations/create_skeleton_table.php.stub')) => rename($file, determineSeparator('./database/migrations/create_' . titleSnake($packageSlugWithoutPrefix) . '_table.php.stub')), - str_contains($file, determineSeparator('config/skeleton.php')) => rename($file, determineSeparator('./config/' . $packageSlugWithoutPrefix . '.php')), - str_contains($file, determineSeparator('resources/lang/en/skeleton.php')) => rename($file, determineSeparator('./resources/lang/en/' . $packageSlugWithoutPrefix . '.php')), - str_contains($file, 'README.md') => removeTag($file, 'delete'), - default => [], - }; -} - -if (! $useDependabot) { - safeUnlink(__DIR__ . '/.github/dependabot.yml'); - safeUnlink(__DIR__ . '/.github/workflows/dependabot-auto-merge.yml'); -} - -if (! $useLaravelRay) { - removeComposerDeps(['spatie/laravel-ray'], 'require-dev'); -} - -if (! $usePhpStan) { - safeUnlink(__DIR__ . '/phpstan.neon.dist'); - safeUnlink(__DIR__ . '/phpstan-baseline.neon'); - safeUnlink(__DIR__ . '/.github/workflows/phpstan.yml'); - - removeComposerDeps([ - 'phpstan/extension-installer', - 'phpstan/phpstan-deprecation-rules', - 'phpstan/phpstan-phpunit', - 'nunomaduro/larastan', - ], 'require-dev'); - - removeComposerDeps(['analyse'], 'scripts'); -} - -if (! $usePint) { - safeUnlink(__DIR__ . '/.github/workflows/fix-php-code-style-issues.yml'); - safeUnlink(__DIR__ . '/pint.json'); - - removeComposerDeps([ - 'laravel/pint', - ], 'require-dev'); - - removeComposerDeps(['format'], 'scripts'); -} - -if (! $useUpdateChangelogWorkflow) { - safeUnlink(__DIR__ . '/.github/workflows/update-changelog.yml'); -} - -confirm('Execute `composer install`?') && run('composer install'); - -if (confirm('Let this script delete itself?', true)) { - unlink(__FILE__); -} - -function ask(string $question, string $default = ''): string -{ - $def = $default ? "\e[0;33m ($default)" : ''; - $answer = readline("\e[0;32m" . $question . $def . ": \e[0m"); - - if (! $answer) { - return $default; - } - - return $answer; -} - -function confirm(string $question, bool $default = false): bool -{ - $answer = ask($question, ($default ? 'Y/n' : 'y/N')); - - if (strtolower($answer) === 'y/n') { - return $default; - } - - return strtolower($answer) === 'y'; -} - -function writeln(string $line): void -{ - echo $line . PHP_EOL; -} - -function run(string $command): string -{ - return trim((string) shell_exec($command)); -} - -function slugify(string $subject): string -{ - return strtolower(trim(preg_replace('/[^A-Za-z0-9-]+/', '-', $subject), '-')); -} - -function titleCase(string $subject): string -{ - return str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $subject))); -} - -function titleSnake(string $subject, string $replace = '_'): string -{ - return str_replace(['-', '_'], $replace, $subject); -} - -function replaceInFile(string $file, array $replacements): void -{ - $contents = file_get_contents($file); - - file_put_contents( - $file, - str_replace( - array_keys($replacements), - array_values($replacements), - $contents - ) - ); -} - -function removePrefix(string $prefix, string $content): string -{ - if (str_starts_with($content, $prefix)) { - return substr($content, strlen($prefix)); - } - - return $content; -} - -function removeComposerDeps(array $names, string $location): void -{ - $data = json_decode(file_get_contents(__DIR__ . '/composer.json'), true); - - foreach ($data[$location] as $name => $version) { - if (in_array($name, $names, true)) { - unset($data[$location][$name]); - } - } - - file_put_contents(__DIR__ . '/composer.json', json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); -} - -function removeNpmDeps(array $names, string $location): void -{ - $data = json_decode(file_get_contents(__DIR__ . '/package.json'), true); - - foreach ($data[$location] as $name => $version) { - if (in_array($name, $names, true)) { - unset($data[$location][$name]); - } - } - - file_put_contents(__DIR__ . '/package.json', json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | - JSON_UNESCAPED_UNICODE)); -} - -function removeTag(string $file, string $tag): void -{ - $contents = file_get_contents($file); - - file_put_contents( - $file, - preg_replace('/.*/s', '', $contents) ?: $contents - ); -} - -function setupPackageJsonForTheme(): void -{ - removeNpmDeps([ - 'purge', - 'dev', - 'dev:scripts', - 'build', - 'build:scripts', - ], 'scripts'); - - removeNpmDeps([ - '@awcodes/filament-plugin-purge', - 'esbuild', - 'npm-run-all', - 'prettier', - 'prettier-plugin-tailwindcss', - ], 'devDependencies'); - - replaceInFile(__DIR__ . '/package.json', [ - 'dev:styles' => 'dev', - 'build:styles' => 'build', - ]); -} - -function safeUnlink(string $filename): void -{ - if (file_exists($filename) && is_file($filename)) { - unlink($filename); - } -} - -function determineSeparator(string $path): string -{ - return str_replace('/', DIRECTORY_SEPARATOR, $path); -} - -function replaceForWindows(): array -{ - return preg_split('/\\r\\n|\\r|\\n/', run('dir /S /B * | findstr /v /i .git\ | findstr /v /i vendor | findstr /v /i ' . basename(__FILE__) . ' | findstr /r /i /M /F:/ ":author :vendor :package VendorName skeleton migration_table_name vendor_name vendor_slug author@domain.com"')); -} - -function replaceForAllOtherOSes(): array -{ - return explode(PHP_EOL, run('grep -E -r -l -i ":author|:vendor|:package|VendorName|skeleton|migration_table_name|vendor_name|vendor_slug|author@domain.com" --exclude-dir=vendor ./* ./.github/* | grep -v ' . basename(__FILE__))); -} - -function removeDirectory($dir): void -{ - if (is_dir($dir)) { - $objects = scandir($dir); - foreach ($objects as $object) { - if ($object != '.' && $object != '..') { - if (filetype($dir . '/' . $object) == 'dir') { - removeDirectory($dir . '/' . $object); - } else { - unlink($dir . '/' . $object); - } - } - } - rmdir($dir); - } -} diff --git a/database/factories/ModelFactory.php b/database/factories/ModelFactory.php index c51604f..a76adb4 100644 --- a/database/factories/ModelFactory.php +++ b/database/factories/ModelFactory.php @@ -1,6 +1,6 @@ id(); + $table->foreignId('dataset_id')->constrained('datasets'); + $table->nullableMorphs('owner'); + $table->nullableMorphs('model'); + $table->string('name')->unique(); + $table->timestamps(); + }); + + } + + public function down(): void + { + Schema::dropIfExists('entities'); + } +}; diff --git a/database/migrations/11_create_entity_values_table.php.stub b/database/migrations/11_create_entity_values_table.php.stub new file mode 100644 index 0000000..f38ce55 --- /dev/null +++ b/database/migrations/11_create_entity_values_table.php.stub @@ -0,0 +1,25 @@ +id(); + $table->foreignId('entity_id')->constrained('entities'); + $table->string('dataset_variable_id')->constrained('dataset_variables'); + $table->text('value'); + $table->timestamps(); + + }); + } + + + public function down(): void + { + Schema::dropIfExists('entity_values'); + } +}; diff --git a/database/migrations/12_create_dataset_variables_table.php.stub b/database/migrations/12_create_dataset_variables_table.php.stub new file mode 100644 index 0000000..6402cd9 --- /dev/null +++ b/database/migrations/12_create_dataset_variables_table.php.stub @@ -0,0 +1,24 @@ +id(); + $table->foreignId('dataset_id')->constrained('datasets'); + $table->string('name'); + $table->timestamps(); + + }); + } + + + public function down(): void + { + Schema::dropIfExists('dataset_variables'); + } +}; diff --git a/database/migrations/13_create_platforms_table.php.stub b/database/migrations/13_create_platforms_table.php.stub new file mode 100644 index 0000000..928e6f3 --- /dev/null +++ b/database/migrations/13_create_platforms_table.php.stub @@ -0,0 +1,25 @@ +id(); + $table->timestamps(); + }); + + // create single entry to represent the current platform for xlsform_template drafts. + Platform::create(); + } + + + public function down(): void + { + Schema::dropIfExists('platforms'); + } +}; diff --git a/database/migrations/14_create_xlsform_template_sections_table.php.stub b/database/migrations/14_create_xlsform_template_sections_table.php.stub new file mode 100644 index 0000000..b1718df --- /dev/null +++ b/database/migrations/14_create_xlsform_template_sections_table.php.stub @@ -0,0 +1,26 @@ +id(); + $table->foreignId('dataset_id')->nullable()->constrained('datasets')->nullOnDelete()->cascadeOnUpdate(); + $table->foreignId('xlsform_template_id')->constrained('xlsform_templates')->cascadeOnUpdate()->cascadeOnDelete(); + $table->string('structure_item')->comment('which schema structure item (group or repeat / root) is this dataset linked to in the form?'); + $table->boolean('is_repeat')->default(false)->comment('Is this dataset linked to a repeat_group structure item?'); + $table->json('schema')->nullable(); + $table->timestamps(); + }); + } + + + public function down(): void + { + Schema::dropIfExists('xlsform_template_sections'); + } +}; diff --git a/database/migrations/15_create_app_user_assignments_table.php.stub b/database/migrations/15_create_app_user_assignments_table.php.stub new file mode 100644 index 0000000..504388b --- /dev/null +++ b/database/migrations/15_create_app_user_assignments_table.php.stub @@ -0,0 +1,26 @@ +unsignedBigInteger('id')->primary(); + $table->foreignId('app_user_id')->constrained('app_users'); + $table->foreignId('xlsform_id')->constrained('xlsforms'); + $table->timestamps(); + }); + } + + + public function down(): void + { + Schema::dropIfExists('app_user_assignments'); + } +}; diff --git a/database/migrations/16_create_media_table.php.stub b/database/migrations/16_create_media_table.php.stub new file mode 100644 index 0000000..3a81908 --- /dev/null +++ b/database/migrations/16_create_media_table.php.stub @@ -0,0 +1,37 @@ +id(); + + $table->morphs('model'); + $table->uuid('uuid')->nullable()->unique(); + $table->string('collection_name'); + $table->string('name'); + $table->string('file_name'); + $table->string('mime_type')->nullable(); + $table->string('disk'); + $table->string('conversions_disk')->nullable(); + $table->unsignedBigInteger('size'); + $table->json('manipulations'); + $table->json('custom_properties'); + $table->json('generated_conversions'); + $table->json('responsive_images'); + $table->unsignedInteger('order_column')->nullable()->index(); + + $table->nullableTimestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('media'); + } + +}; diff --git a/database/migrations/17_create_permission_tables.php.stub b/database/migrations/17_create_permission_tables.php.stub new file mode 100644 index 0000000..7c71236 --- /dev/null +++ b/database/migrations/17_create_permission_tables.php.stub @@ -0,0 +1,139 @@ +bigIncrements('id'); // permission id + $table->string('name'); // For MySQL 8.0 use string('name', 125); + $table->string('guard_name'); // For MySQL 8.0 use string('guard_name', 125); + $table->timestamps(); + + $table->unique(['name', 'guard_name']); + }); + + Schema::create($tableNames['roles'], function (Blueprint $table) use ($teams, $columnNames) { + $table->bigIncrements('id'); // role id + if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing + $table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable(); + $table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index'); + } + $table->string('name'); // For MySQL 8.0 use string('name', 125); + $table->string('guard_name'); // For MySQL 8.0 use string('guard_name', 125); + $table->timestamps(); + if ($teams || config('permission.testing')) { + $table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']); + } else { + $table->unique(['name', 'guard_name']); + } + }); + + Schema::create($tableNames['model_has_permissions'], function (Blueprint $table) use ($tableNames, $columnNames, $teams) { + $table->unsignedBigInteger(PermissionRegistrar::$pivotPermission); + + $table->string('model_type'); + $table->unsignedBigInteger($columnNames['model_morph_key']); + $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index'); + + $table->foreign(PermissionRegistrar::$pivotPermission) + ->references('id') // permission id + ->on($tableNames['permissions']) + ->onDelete('cascade'); + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key']); + $table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index'); + + $table->primary([$columnNames['team_foreign_key'], PermissionRegistrar::$pivotPermission, $columnNames['model_morph_key'], 'model_type'], + 'model_has_permissions_permission_model_type_primary'); + } else { + $table->primary([PermissionRegistrar::$pivotPermission, $columnNames['model_morph_key'], 'model_type'], + 'model_has_permissions_permission_model_type_primary'); + } + + }); + + Schema::create($tableNames['model_has_roles'], function (Blueprint $table) use ($tableNames, $columnNames, $teams) { + $table->unsignedBigInteger(PermissionRegistrar::$pivotRole); + + $table->string('model_type'); + $table->unsignedBigInteger($columnNames['model_morph_key']); + $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index'); + + $table->foreign(PermissionRegistrar::$pivotRole) + ->references('id') // role id + ->on($tableNames['roles']) + ->onDelete('cascade'); + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key']); + $table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index'); + + $table->primary([$columnNames['team_foreign_key'], PermissionRegistrar::$pivotRole, $columnNames['model_morph_key'], 'model_type'], + 'model_has_roles_role_model_type_primary'); + } else { + $table->primary([PermissionRegistrar::$pivotRole, $columnNames['model_morph_key'], 'model_type'], + 'model_has_roles_role_model_type_primary'); + } + }); + + Schema::create($tableNames['role_has_permissions'], function (Blueprint $table) use ($tableNames) { + $table->unsignedBigInteger(PermissionRegistrar::$pivotPermission); + $table->unsignedBigInteger(PermissionRegistrar::$pivotRole); + + $table->foreign(PermissionRegistrar::$pivotPermission) + ->references('id') // permission id + ->on($tableNames['permissions']) + ->onDelete('cascade'); + + $table->foreign(PermissionRegistrar::$pivotRole) + ->references('id') // role id + ->on($tableNames['roles']) + ->onDelete('cascade'); + + $table->primary([PermissionRegistrar::$pivotPermission, PermissionRegistrar::$pivotRole], 'role_has_permissions_permission_id_role_id_primary'); + }); + + app('cache') + ->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null) + ->forget(config('permission.cache.key')); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + $tableNames = config('permission.table_names'); + + if (empty($tableNames)) { + throw new \Exception('Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.'); + } + + Schema::drop($tableNames['role_has_permissions']); + Schema::drop($tableNames['model_has_roles']); + Schema::drop($tableNames['model_has_permissions']); + Schema::drop($tableNames['roles']); + Schema::drop($tableNames['permissions']); + } +}; diff --git a/database/migrations/18_create_teams_table.php.stub b/database/migrations/18_create_teams_table.php.stub new file mode 100644 index 0000000..d947392 --- /dev/null +++ b/database/migrations/18_create_teams_table.php.stub @@ -0,0 +1,29 @@ +id(); + $table->string('name'); + $table->text('description')->nullable(); + $table->string('avatar')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('teams'); + } +}; diff --git a/database/migrations/19_create_role_invites_table.php.stub b/database/migrations/19_create_role_invites_table.php.stub new file mode 100644 index 0000000..e9cc22f --- /dev/null +++ b/database/migrations/19_create_role_invites_table.php.stub @@ -0,0 +1,31 @@ +id(); + $table->string('email'); + $table->foreignId('role_id')->constrained()->onDelete('cascade'); + $table->foreignId('inviter_id')->constrained('users')->onDelete('cascade'); + $table->string('token'); + $table->tinyInteger('is_confirmed')->default(0); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('role_invites'); + } +}; diff --git a/database/migrations/1_create_datasets_table.php.stub b/database/migrations/1_create_datasets_table.php.stub new file mode 100644 index 0000000..032f097 --- /dev/null +++ b/database/migrations/1_create_datasets_table.php.stub @@ -0,0 +1,26 @@ +id(); + $table->string('name')->unique(); + $table->string('primary_key'); + $table->text('description')->nullable(); + $table->string('entity_model')->nullable(); + $table->boolean('external_file')->default(false)->comment('Should the csv files generated be formatted to be used with "select_one_from_external" ODK fields?'); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('datasets'); + } +}; diff --git a/database/migrations/20_create_team_invites_table.php.stub b/database/migrations/20_create_team_invites_table.php.stub new file mode 100644 index 0000000..36bfc5f --- /dev/null +++ b/database/migrations/20_create_team_invites_table.php.stub @@ -0,0 +1,31 @@ +id(); + $table->string('email'); + $table->foreignId('team_id')->constrained()->onDelete('cascade'); + $table->foreignId('inviter_id'); + $table->string('token'); + $table->tinyInteger('is_confirmed')->default(0); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('team_invites'); + } +}; diff --git a/database/migrations/21_create_team_members_table.php.stub b/database/migrations/21_create_team_members_table.php.stub new file mode 100644 index 0000000..06aa3db --- /dev/null +++ b/database/migrations/21_create_team_members_table.php.stub @@ -0,0 +1,29 @@ +bigIncrements('id'); + $table->foreignId('team_id')->constrained('teams')->onUpdate('cascade')->onDelete('cascade'); + $table->foreignId('user_id')->constrained('users')->onUpdate('cascade')->onDelete('cascade'); + $table->boolean('is_admin')->default(false); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('team_members'); + } +}; diff --git a/database/migrations/2_create_xlsform_templates_table.php.stub b/database/migrations/2_create_xlsform_templates_table.php.stub new file mode 100644 index 0000000..2bc60f6 --- /dev/null +++ b/database/migrations/2_create_xlsform_templates_table.php.stub @@ -0,0 +1,48 @@ +id(); + $table->string('title'); + $table->text('description')->nullable(); + + $table->boolean('available')->default(0)->comment('Available to all users? If false, the form is only available to testers or admins.'); + + // Who does the template belong to? (By default, this will be "the platform". This is to enable admins to test a draft version of the form before sharing with teams) + $table->foreignId('owner_id')->nullable(); + $table->string('owner_type')->nullable(); + + // odk draft details - Each template gets deployed as a draft for testing before it is shared with users. + $table->string('odk_id')->nullable()->comment('The unique ID of the form on ODK service. If null, the form has not yet been pushed to ODK Central.'); + $table->string('odk_draft_token')->nullable()->comment('ODK Central only: The current draft token, required to generate a QR code for testing the draft in ODK Collect'); + $table->string('has_draft')->nullable()->comment('Does the form have a deployed draft?'); + $table->string('enketo_draft_id')->nullable()->comment('id component of the enketo version - pulled from the ODK service if supported/enabled'); + $table->boolean('draft_needs_updating')->default(0)->comment('Set to true if the form has been updated since the last draft was deployed to ODK Central'); + $table->text('odk_error')->nullable()->comment('If a xlsfile upload results in an ODK syntax error, it will be stored here. For working forms, this will be null'); + $table->string('odk_version_id')->nullable(); + + + + // The full schema of the form, as a json object + $table->json('schema')->nullable(); + + // which dataset does this form's submissions populate? + $table->foreignId('main_dataset_id')->nullable()->constrained('datasets')->nullOnDelete()->cascadeOnUpdate(); + + // system stuff + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('xlsform_templates'); + } +}; diff --git a/database/migrations/3_create_xlsforms_table.php.stub b/database/migrations/3_create_xlsforms_table.php.stub new file mode 100644 index 0000000..e7f5896 --- /dev/null +++ b/database/migrations/3_create_xlsforms_table.php.stub @@ -0,0 +1,45 @@ +id(); + $table->foreignId('xlsform_template_id')->constrained('xlsform_templates'); + + $table->foreignId('owner_id'); + $table->string('owner_type'); + // direct link to the owner's project on ODK Central. + $table->foreignId('odk_project_id')->nullable(); + + $table->string('title')->nullable()->comment('If null, the system by default retrieves a title in the format $ownerName - $xlsformTemplateTitle'); + + // ODK deployment stuff + $table->string('odk_id')->nullable()->comment('The unique ID of the form on ODK service. If null, the form has not yet been pushed to ODK Central.'); + $table->string('odk_draft_token')->nullable()->comment('ODK Central only: The current draft token, required to generate a QR code for testing the draft in ODK Collect'); + $table->string('odk_version_id')->nullable()->comment('current or most recently deployed version on the ODK service. If null, the form has not yet been deployed on ODK Central.'); + $table->string('has_draft')->nullable()->comment('Does the form have a deployed draft?'); + $table->string('is_active')->nullable()->comment('is the form active and accepting submissions?'); + $table->string('enketo_draft_id')->nullable()->comment('unique id - part of the url to the enketo version - pulled from the ODK service if supported/enabled'); + $table->string('enketo_id')->nullable()->comment('unique id for the enketo version - pulled from the ODK service if supported/enabled'); + $table->boolean('processing')->default(0)->comment('Is the form currently being processed? (helps to avoid duplicate deployments)'); + $table->text('odk_error')->nullable()->comment('If an xlsfile upload returns an error from the ODK Aggregate service, it will be stored here'); + $table->timestamps(); + + // The full schema of the form, as a json object + $table->json('schema')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('xlsforms'); + } +}; diff --git a/database/migrations/4_create_xlsform_versions_table.php.stub b/database/migrations/4_create_xlsform_versions_table.php.stub new file mode 100644 index 0000000..fe67f8e --- /dev/null +++ b/database/migrations/4_create_xlsform_versions_table.php.stub @@ -0,0 +1,35 @@ +id(); + $table->foreignId('xlsform_id')->constrained('xlsforms'); + + $table->string('xlsfile'); + + $table->string('version'); + $table->string('odk_version'); + + // yes, we're keeping the schema at the template, xlsform and version level... + $table->json('schema')->nullable(); + + + $table->boolean('active')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('xlsform_versions'); + } +}; diff --git a/database/migrations/5_create_submissions_table.php.stub b/database/migrations/5_create_submissions_table.php.stub new file mode 100644 index 0000000..48dffa7 --- /dev/null +++ b/database/migrations/5_create_submissions_table.php.stub @@ -0,0 +1,33 @@ +id(); + $table->string('odk_id')->unique(); + $table->foreignId('xlsform_version_id')->constrained('xlsform_versions'); + $table->timestamp('submitted_at'); + $table->string('submitted_by')->nullable(); + $table->longtext('content'); // This is explicitly not json so the ordering of variables is preserved (at the expense of not being able to query the content in SQL); + $table->json('errors')->nullable(); + $table->boolean('processed')->default(0); + + // what data model entries were created when processing this submission? (e.g., if the application has custom data maps that populate tables from processed submissions. + $table->json('entries')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('submissions'); + } +}; diff --git a/database/migrations/6_create_required_media_table.php.stub b/database/migrations/6_create_required_media_table.php.stub new file mode 100644 index 0000000..387ddc9 --- /dev/null +++ b/database/migrations/6_create_required_media_table.php.stub @@ -0,0 +1,33 @@ +id(); + $table->foreignId('dataset_id')->nullable()->constrained('datasets'); + $table->foreignId('xlsform_template_id')->constrained('xlsform_templates')->cascadeOnDelete()->cascadeOnUpdate(); + + $table->string('name'); + $table->string('type'); + + // will the media uploaded to the ODK form be a static file? (If false, it will be generated from a database table/view. + $table->boolean('is_static')->default(true); + $table->boolean('exists_on_odk')->default(false); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('required_media'); + } +}; diff --git a/database/migrations/7_create_odk_datasets_table.php.stub b/database/migrations/7_create_odk_datasets_table.php.stub new file mode 100644 index 0000000..66e14d9 --- /dev/null +++ b/database/migrations/7_create_odk_datasets_table.php.stub @@ -0,0 +1,30 @@ +id(); + $table->foreignId('owner_id'); + $table->string('owner_type'); + + $table->string('name'); + $table->text('description')->nullable(); + + $table->boolean('archived')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('odk_datasets'); + } +}; diff --git a/database/migrations/8_create_odk_projects_table.php.stub b/database/migrations/8_create_odk_projects_table.php.stub new file mode 100644 index 0000000..a741660 --- /dev/null +++ b/database/migrations/8_create_odk_projects_table.php.stub @@ -0,0 +1,28 @@ +unsignedBigInteger('id')->primary(); + $table->foreignId('owner_id'); + $table->string('owner_type'); + + $table->string('name'); + $table->text('description')->nullable(); + + $table->boolean('archived')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('odk_projects'); + } +}; diff --git a/database/migrations/9_create_app_users_table.php.stub b/database/migrations/9_create_app_users_table.php.stub new file mode 100644 index 0000000..403694f --- /dev/null +++ b/database/migrations/9_create_app_users_table.php.stub @@ -0,0 +1,29 @@ +id(); + $table->foreignId('odk_project_id')->constrained('odk_projects'); + $table->string('display_name'); + $table->string('type'); + $table->string('token'); + + $table->boolean('can_access_all_forms')->default(0)->comment('App users might be assigned the "admin" role in a project; which automatically gives them access to all forms. Otherwise, they are manually assigned to specific forms via the app_user_assignments table'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('app_users'); + } +}; diff --git a/database/migrations/create_skeleton_table.php.stub b/database/migrations/create_skeleton_table.php.stub deleted file mode 100644 index 2efdce9..0000000 --- a/database/migrations/create_skeleton_table.php.stub +++ /dev/null @@ -1,19 +0,0 @@ -id(); - - // add fields - - $table->timestamps(); - }); - } -}; diff --git a/package.json b/package.json index afec3c0..6ddc168 100644 --- a/package.json +++ b/package.json @@ -2,11 +2,11 @@ "private": true, "type": "module", "scripts": { - "dev:styles": "npx tailwindcss -i resources/css/index.css -o resources/dist/skeleton.css --postcss --watch", + "dev:styles": "npx tailwindcss -i resources/css/index.css -o resources/dist/filament-odk-link.css --postcss --watch", "dev:scripts": "node bin/build.js --dev", - "build:styles": "npx tailwindcss -i resources/css/index.css -o resources/dist/skeleton.css --postcss --minify && npm run purge", + "build:styles": "npx tailwindcss -i resources/css/index.css -o resources/dist/filament-odk-link.css --postcss --minify && npm run purge", "build:scripts": "node bin/build.js", - "purge": "filament-purge -i resources/dist/skeleton.css -o resources/dist/skeleton.css -v 3.x", + "purge": "filament-purge -i resources/dist/filament-odk-link.css -o resources/dist/filament-odk-link.css -v 3.x", "dev": "npm-run-all --parallel dev:*", "build": "npm-run-all build:*" }, diff --git a/phpunit.xml.dist b/phpunit.xml.dist index e953c0e..2262ea8 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -16,7 +16,7 @@ backupStaticProperties="false" > - + tests diff --git a/resources/lang/en/odk-link.php b/resources/lang/en/odk-link.php new file mode 100644 index 0000000..cc2205a --- /dev/null +++ b/resources/lang/en/odk-link.php @@ -0,0 +1,6 @@ +_ diff --git a/resources/views/filament/forms/components/clickable-link.blade.php b/resources/views/filament/forms/components/clickable-link.blade.php new file mode 100644 index 0000000..be1cab5 --- /dev/null +++ b/resources/views/filament/forms/components/clickable-link.blade.php @@ -0,0 +1,8 @@ + +
+ {{ $getRecord()->enketo_draft_url }} +
+
diff --git a/resources/views/filament/forms/components/draft-testing-qr-code.blade.php b/resources/views/filament/forms/components/draft-testing-qr-code.blade.php new file mode 100644 index 0000000..8ecdf5b --- /dev/null +++ b/resources/views/filament/forms/components/draft-testing-qr-code.blade.php @@ -0,0 +1,8 @@ + +
+ {{ QrCode::size(150)->generate($getRecord()->draftQrCodeString) }} +
+
diff --git a/resources/views/filament/forms/components/form-review.blade.php b/resources/views/filament/forms/components/form-review.blade.php new file mode 100644 index 0000000..8939096 --- /dev/null +++ b/resources/views/filament/forms/components/form-review.blade.php @@ -0,0 +1,6 @@ +
+ {{ $getRecord()->title }} + +

Fixed Media Count

+ {{ $getRecord()->requiredFixedMedia()->count() }}; +
diff --git a/resources/views/filament/forms/components/html-block.blade.php b/resources/views/filament/forms/components/html-block.blade.php new file mode 100644 index 0000000..a74085a --- /dev/null +++ b/resources/views/filament/forms/components/html-block.blade.php @@ -0,0 +1,15 @@ + +
merge($getExtraAttributes(), escape: false) + ->class(['fi-fo-placeholder sm:text-sm']) + }} + > + {{ $getContent() }} +
+
diff --git a/resources/views/filament/forms/components/xlsform-section-schema-modal-link.blade.php b/resources/views/filament/forms/components/xlsform-section-schema-modal-link.blade.php new file mode 100644 index 0000000..90ae002 --- /dev/null +++ b/resources/views/filament/forms/components/xlsform-section-schema-modal-link.blade.php @@ -0,0 +1,6 @@ +
+ There are {{ $getRecord()->schema->count() }} variables in the {{ $getRecord()->structure_item === 'root' ? 'main' : $getRecord()->structure_item }} section. +

+ {{ $getAction('viewSchema') }} + +
diff --git a/resources/views/filament/infolists/components/team-qr-code.blade.php b/resources/views/filament/infolists/components/team-qr-code.blade.php new file mode 100644 index 0000000..617f478 --- /dev/null +++ b/resources/views/filament/infolists/components/team-qr-code.blade.php @@ -0,0 +1,4 @@ +
+ {{ QrCode::size(150)->generate($getRecord()->odkProject->appUsers->first()->qr_code_string) }} +
SCAN QR Code in ODK Collect
+
diff --git a/resources/views/filament/infolists/components/xlsform-section-schema-modal-link.blade.php b/resources/views/filament/infolists/components/xlsform-section-schema-modal-link.blade.php new file mode 100644 index 0000000..28864ea --- /dev/null +++ b/resources/views/filament/infolists/components/xlsform-section-schema-modal-link.blade.php @@ -0,0 +1,6 @@ +
+ There are {{ $getRecord()->rootSection->schema->count() }} variables in the main section of the survey. +

+ {{ $getAction('viewSchema') }} + +
diff --git a/resources/views/filament/infolists/entries/draft-testing-qr-code.blade.php b/resources/views/filament/infolists/entries/draft-testing-qr-code.blade.php new file mode 100644 index 0000000..3fcb774 --- /dev/null +++ b/resources/views/filament/infolists/entries/draft-testing-qr-code.blade.php @@ -0,0 +1,5 @@ + +
+ {{ QrCode::size(150)->generate($getRecord()->draftQrCodeString) }} +
+
diff --git a/resources/views/filament/tables/columns/draft-qr-code.blade.php b/resources/views/filament/tables/columns/draft-qr-code.blade.php new file mode 100644 index 0000000..a909f49 --- /dev/null +++ b/resources/views/filament/tables/columns/draft-qr-code.blade.php @@ -0,0 +1,7 @@ +
+ @if($getRecord()->draft_qr_code_string) + {{ QrCode::size(100)->generate($getRecord()->draft_qr_code_string) }} + @else +

No QR Code

+ @endif +
diff --git a/resources/views/filament/tables/columns/required-data-media-count.blade.php b/resources/views/filament/tables/columns/required-data-media-count.blade.php new file mode 100644 index 0000000..9c9c67c --- /dev/null +++ b/resources/views/filament/tables/columns/required-data-media-count.blade.php @@ -0,0 +1,8 @@ +@php + $requiredMediaCount = $getRecord()->requiredDataMedia()->count(); + $attachedMediaCount = $getRecord()->attachedDataMedia()->count(); +@endphp + +
+ {{ $attachedMediaCount }} / {{ $requiredMediaCount }} +
diff --git a/resources/views/filament/tables/columns/required-fixed-media-count.blade.php b/resources/views/filament/tables/columns/required-fixed-media-count.blade.php new file mode 100644 index 0000000..84475d2 --- /dev/null +++ b/resources/views/filament/tables/columns/required-fixed-media-count.blade.php @@ -0,0 +1,8 @@ +@php + $requiredMediaCount = $getRecord()->requiredFixedMedia()->count(); + $attachedMediaCount = $getRecord()->attachedFixedMedia()->count(); +@endphp + +
+ {{ $attachedMediaCount }} / {{ $requiredMediaCount }} +
diff --git a/resources/views/filament/tables/columns/team-datasets-required.blade.php b/resources/views/filament/tables/columns/team-datasets-required.blade.php new file mode 100644 index 0000000..b33fd27 --- /dev/null +++ b/resources/views/filament/tables/columns/team-datasets-required.blade.php @@ -0,0 +1,10 @@ +@php + +// find the team datasets required from the xlsformtemplate +$datasets = $getRecord()->xlsformTemplate->requiredDataMedia->where('dataset_id', '!=', null)->pluck('dataset_id')->unique(); + +@endphp + +
+ {{ $datasets->count() }} +
diff --git a/src/Commands/SkeletonCommand.php b/src/Commands/FilamentOdkLinkCommand.php similarity index 59% rename from src/Commands/SkeletonCommand.php rename to src/Commands/FilamentOdkLinkCommand.php index 3e5f628..28799be 100644 --- a/src/Commands/SkeletonCommand.php +++ b/src/Commands/FilamentOdkLinkCommand.php @@ -1,12 +1,12 @@ schema(self::getCreateFormFields()); + } + + public static function getCreateFormFields(): array + { + return [ + Forms\Components\TextInput::make('name') + ->required() + ->unique(ignoreRecord: true), + Forms\Components\TextInput::make('primary_key') + ->hint('If this dataset is being populated by and ODK form, this field should be present in the form.') + ->required(), + Forms\Components\TextArea::make('description'), + ]; + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name'), + Tables\Columns\TextColumn::make('primary_key'), + Tables\Columns\TextColumn::make('description') + ->limit(50), + ]) + ->filters([ + // + ]) + ->actions([ + Tables\Actions\ViewAction::make(), + Tables\Actions\EditAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } + + public static function infolist(Infolist $infolist): Infolist + { + return $infolist + ->schema([ + Section::make('Dataset Details') + ->schema([ + TextEntry::make('name'), + TextEntry::make('primary_key'), + TextEntry::make('description'), + ]) + ->columns([ + 'lg' => 3, + 'md' => 2, + 'sm' => 1, + ]) + ]); + } + + public static function getRelations(): array + { + return [ + RelationManagers\XlsformTemplateSourcesRelationManager::class, + RelationManagers\XlsformTemplatesRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListDatasets::route('/'), + 'create' => Pages\CreateDataset::route('/create'), + 'edit' => Pages\EditDataset::route('/{record}/edit'), + 'view' => Pages\ViewDataset::route('/{record}'), + ]; + } +} diff --git a/src/Filament/Resources/DatasetResource/Pages/CreateDataset.php b/src/Filament/Resources/DatasetResource/Pages/CreateDataset.php new file mode 100644 index 0000000..a46bd35 --- /dev/null +++ b/src/Filament/Resources/DatasetResource/Pages/CreateDataset.php @@ -0,0 +1,12 @@ +getRecord()->name; + } +} diff --git a/src/Filament/Resources/DatasetResource/RelationManagers/XlsformTemplateSourcesRelationManager.php b/src/Filament/Resources/DatasetResource/RelationManagers/XlsformTemplateSourcesRelationManager.php new file mode 100644 index 0000000..6ab9294 --- /dev/null +++ b/src/Filament/Resources/DatasetResource/RelationManagers/XlsformTemplateSourcesRelationManager.php @@ -0,0 +1,61 @@ +schema([ + Forms\Components\TextInput::make('title') + ->required() + ->maxLength(255), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('title') + ->columns([ + Tables\Columns\TextColumn::make('title') + ->label('Xlsform Template') + ->url(fn (Model $record) => XlsformTemplateResource::getUrl('view', ['record' => $record])), + Tables\Columns\TextColumn::make('active_xlsforms_count')->counts('xlsforms') + ->label('# of Active XLsforms') + ]) + ->filters([ + // + ]) + ->headerActions([ + Tables\Actions\CreateAction::make(), + ]) + ->actions([ + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/src/Filament/Resources/DatasetResource/RelationManagers/XlsformTemplatesRelationManager.php b/src/Filament/Resources/DatasetResource/RelationManagers/XlsformTemplatesRelationManager.php new file mode 100644 index 0000000..b70b745 --- /dev/null +++ b/src/Filament/Resources/DatasetResource/RelationManagers/XlsformTemplatesRelationManager.php @@ -0,0 +1,58 @@ +schema([ + Forms\Components\TextInput::make('title') + ->required() + ->maxLength(255), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('title') + ->columns([ + Tables\Columns\TextColumn::make('title') + ->label('Xlsform Template'), + Tables\Columns\TextColumn::make('active_xlsforms_count')->counts('xlsforms') + ->label('# of Active Xlsforms')]) + ->filters([ + // + ]) + ->headerActions([ + Tables\Actions\CreateAction::make(), + ]) + ->actions([ + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/src/Filament/Resources/TeamResource.php b/src/Filament/Resources/TeamResource.php new file mode 100644 index 0000000..f04a058 --- /dev/null +++ b/src/Filament/Resources/TeamResource.php @@ -0,0 +1,104 @@ +schema([ + Forms\Components\Section::make('Team Details') + ->schema([ + + Forms\Components\TextInput::make('name') + ->required(), + Forms\Components\Textarea::make('description'), + Forms\Components\FileUpload::make('avatar'), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\ImageColumn::make('avatar'), + Tables\Columns\TextColumn::make('name'), + Tables\Columns\TextColumn::make('xlsform_count') + ->label('Xlsforms') + ->counts('xlsforms'), + ]) + ->filters([ + // + ]) + ->actions([ + Tables\Actions\ViewAction::make(), + Tables\Actions\EditAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } + + public static function infolist(Infolist $infolist): Infolist + { + return $infolist + ->schema([ + Section::make('Team Details') + ->columns(6) + ->schema([ + ImageEntry::make('avatar') + ->label('') + ->columnSpan(2), + TextEntry::make('description') + ->getStateUsing(fn ($record) => new HtmlString(preg_replace('/\n/', '
', $record->description))) + ->columnSpan(4), + ViewEntry::make('qr_code') + ->view('filament.infolists.components.team-qr-code') + + ]), + ]); + } + + public static function getRelations(): array + { + return [ + TeamResource\RelationManagers\XlsformsRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListTeams::route('/'), + 'create' => Pages\CreateTeam::route('/create'), + 'edit' => Pages\EditTeam::route('/{record}/edit'), + 'view' => Pages\ViewTeam::route('/{record}'), + ]; + } +} diff --git a/src/Filament/Resources/TeamResource/Pages/CreateTeam.php b/src/Filament/Resources/TeamResource/Pages/CreateTeam.php new file mode 100644 index 0000000..26bf0b1 --- /dev/null +++ b/src/Filament/Resources/TeamResource/Pages/CreateTeam.php @@ -0,0 +1,12 @@ + $this->record]); + } +} diff --git a/src/Filament/Resources/TeamResource/Pages/ListTeams.php b/src/Filament/Resources/TeamResource/Pages/ListTeams.php new file mode 100644 index 0000000..62933ba --- /dev/null +++ b/src/Filament/Resources/TeamResource/Pages/ListTeams.php @@ -0,0 +1,19 @@ +getRecord()->name; + } + + protected function getHeaderActions(): array + { + return [ + Actions\EditAction::make(), + Actions\DeleteAction::make(), + ]; + } +} diff --git a/src/Filament/Resources/TeamResource/RelationManagers/XlsformsRelationManager.php b/src/Filament/Resources/TeamResource/RelationManagers/XlsformsRelationManager.php new file mode 100644 index 0000000..d5c7de1 --- /dev/null +++ b/src/Filament/Resources/TeamResource/RelationManagers/XlsformsRelationManager.php @@ -0,0 +1,117 @@ +columns(1) + ->schema([ + Forms\Components\Select::make('xlsform_template_id') + ->relationship( + name: 'xlsformTemplate', + titleAttribute: 'title', + modifyQueryUsing: fn(Builder $query) => $query->where('available', true) + ) + ->live() + ->afterStateUpdated(fn(Forms\Set $set, $state) => $set('title', $state ? XlsformTemplate::find($state)->title : '')), + + Forms\Components\TextInput::make('title') + ->helperText('By default, this is the title of the Template you select. If you want multiple instances of the same form template, you should give each a unique title.') + ->required() + ->maxLength(255) + ->unique(ignoreRecord: true), + + // show QR code + ViewField::make('qr_code') + ->label('Scan the QR code below in ODK Collect to view the test form.') + ->view('filament.forms.components.draft-testing-qr-code'), + + // show Enteko link as a clickable link + ViewField::make('enketo_draft_url') + ->label('Click below link to view ODK form in browser') + ->view('filament.forms.components.clickable-link') + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('title') + ->columns([ + Tables\Columns\TextColumn::make('title') + ->grow(false), + Tables\Columns\TextColumn::make('status'), + Tables\Columns\ViewColumn::make('team_datasets_required') + ->view('filament.tables.columns.team-datasets-required'), + Tables\Columns\TextColumn::make('submissions_count')->counts('submissions') + ->label('No. of Submissions'), + ]) + ->filters([ + // + ]) + ->headerActions([ + Tables\Actions\CreateAction::make() + ->label('Add Xlsform to Team') + ->after(function (Xlsform $record) { + + $odkLinkService = app()->make(OdkLinkService::class); + + if (!$record->xlsfile) { + $record->updateXlsfileFromTemplate(); + } + + UpdateXlsformTitleInFile::dispatchSync($record); + + $record->refresh(); + $record->deployDraft($odkLinkService); + + }), + ]) + ->actions([ + Tables\Actions\ViewAction::make(), + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } + + public function infoList(Infolist $infoList): Infolist + { + return $infoList + ->columns([ + Tables\Columns\TextColumn::make('title'), + Tables\Columns\TextColumn::make('status'), + Tables\Columns\TextColumn::make('submissions_count')->counts('submissions') + ->label('No. of Submissions'), + ]); + } +} diff --git a/src/Filament/Resources/XlsformTemplateResource.php b/src/Filament/Resources/XlsformTemplateResource.php new file mode 100644 index 0000000..466bbed --- /dev/null +++ b/src/Filament/Resources/XlsformTemplateResource.php @@ -0,0 +1,478 @@ +columns(1) + ->schema([ + Tabs::make('Label') + ->tabs([ + Tabs\Tab::make('Xlsform File') + ->schema(static::getCreateFields()), + Tabs\Tab::make('Attached Media Files') + ->schema(static::getStaticMediaFields()), + Tabs\Tab::make('Attached Datasets') + ->schema(static::getDatasetMediaFields()), + ]) + ]); + } + + public static function getCreateFields(): array + { + return [ + Forms\Components\TextInput::make('title') + ->autofocus() + ->required() + ->maxLength(255) + ->placeholder(__('Title')), + Forms\Components\SpatieMediaLibraryFileUpload::make('xlsfile') + ->collection('xlsform_file') + ->preserveFilenames() + ->downloadable() + ->autofocus() + ->required() + ->placeholder(__('File')), + ]; + } + + public static function getStaticMediaFields(): array + { + + return [ + Forms\Components\Repeater::make('requiredFixedMedia') + ->label(function (?XlsformTemplate $record) { + + $label = "

Add Media Files

"; + + if ($record?->requiredFixedMedia()->count() > 0) { + $label .= "

The Form requires the following media items. Please upload each one here.

"; + } else { + $label .= "

This form does not require any media files. You may skip this step

"; + } + + return new HtmlString($label); + }) + ->relationship() + ->addable(false) + ->deletable(false) + ->schema([ + + HtmlBlock::make('name') + ->content(fn(?RequiredMedia $record): HtmlString => new HtmlString("Filename: {$record?->name}") + ), + + Forms\Components\SpatieMediaLibraryFileUpload::make('file') + ->preserveFilenames() + ->downloadable() + ->required() + ]), + ]; + } + + public static function getDatasetMediaFields() + { + return [ + Forms\Components\Repeater::make('requiredDataMedia') + ->label(function (?XlsformTemplate $record) { + $label = "

Link Required Datasets

"; + + if ($record?->requiredFixedMedia()->count() > 0) { + $label .= "

The Form requires the following media items. Please upload each one here.

"; + } else { + $label .= "

This form does not require any media files. You may skip this step

"; + } + + return new HtmlString($label); + }) + ->relationship() + ->addable(false) + ->deletable(false) + ->schema([ + + HtmlBlock::make('name') + ->content(fn(?RequiredMedia $record): HtmlString => new HtmlString("Filename: {$record?->name}") + ), + Forms\Components\Toggle::make('is_static') + ->label('Is this a static media file?') + ->default(false) + ->live(), + + // for static media + Forms\Components\SpatieMediaLibraryFileUpload::make('file') + ->preserveFilenames() + ->downloadable() + ->required() + ->visible(fn(Get $get): bool => $get('is_static')), + + // for non-static media (linked to datasets) + Forms\Components\Select::make('dataset_id') + ->label('Select a dataset') + ->relationship('dataset', 'name') + ->visible(fn(Get $get): bool => !$get('is_static')), + + ]) + ]; + } + + + public static function getXlsformSectionFields(): array + { + return [ + + HtmlBlock::make('title') + ->content(fn(?XlsformTemplate $record): HtmlString => new HtmlString(" +

{$record->title} - Form Structure

+

On this page, you can review the structure of the data that will come from form submissions. The 'main survey' section includes all the variables that are not in repeat groups. You should choose or create a dataset for the form submissions to populate.

+ + ")), + Forms\Components\Fieldset::make('rootSection') + ->label('Main Survey') + ->relationship('rootSection') + ->schema([ + Forms\Components\ViewField::make('schema') + ->view('filament.forms.components.xlsform-section-schema-modal-link') + ->registerActions([ + Action::make('viewSchema') + ->label('View variable list') + ->icon('heroicon-o-eye') + ->form(function (?XlsformTemplateSection $record) { + return [ + TableRepeater::make('schema') + ->label('List of variables in the main survey') + ->deletable(false) + ->reorderable(false) + ->addable(false) + ->schema([ + Forms\Components\TextInput::make('name')->disabled()->hiddenLabel(), + Forms\Components\TextInput::make('type')->disabled()->hiddenLabel(), + ]), + ]; + }) + ->fillForm(fn(?XlsformTemplateSection $record): array => [ + 'schema' => $record->schema, + ]) + ->modalSubmitAction(false) + ->modalCancelActionLabel('Close') + ]) + ->visible(fn(?XlsformTemplateSection $record): bool => $record->schema->count() >= 5), + + Forms\Components\Select::make('dataset_id') + ->relationship('dataset', 'name') + ->label('Select which dataset the submissions should be linked to') + ->createOptionForm(DatasetResource::getCreateFormFields()) + ->createOptionModalHeading("Create New Dataset") + ]), + + Forms\Components\Repeater::make('repeatingSections') + ->columns([ + 'md' => 2, + 'sm' => 1 + ]) + ->label(function (?XlsformTemplate $record) { + $label = "

Repeat Groups

This form also has {$record->repeatingSections()->count()} repeat groups within the form. The data from these repeat groups should be linked to a different dataset. For example, in a household survey, you may link the 'main' survey submission data to a dataset called 'Households', and a repeat group asking information from each member to a dataset called 'Household Members'.


"; + + return new HtmlString($label); + }) + ->itemLabel(fn(array $state): ?string => $state['structure_item'] ?? null) + ->visible(fn(?XlsformTemplate $record): bool => $record->repeatingSections()->count() > 0) + ->relationship() + ->addable(false) + ->deletable(false) + ->schema([ + Forms\Components\ViewField::make('schema') + ->view('filament.forms.components.xlsform-section-schema-modal-link') + ->registerActions([ + Action::make('viewSchema') + ->label('View variable list') + ->icon('heroicon-o-eye') + ->form(function (?XlsformTemplateSection $record) { + return [ + TableRepeater::make('schema') + ->label(fn(?XlsformTemplateSection $record) => "List of variables in the {$record->structure_item} repeat group") + ->deletable(false) + ->reorderable(false) + ->addable(false) + ->schema([ + Forms\Components\TextInput::make('name')->disabled()->hiddenLabel(), + Forms\Components\TextInput::make('type')->disabled()->hiddenLabel(), + ]), + ]; + }) + ->fillForm(fn(?XlsformTemplateSection $record): array => [ + 'schema' => $record->schema, + ]) + ->modalSubmitAction(false) + ->modalCancelActionLabel('Close') + ]), + Forms\Components\Select::make('dataset_id') + ->relationship('dataset', 'name') + ->label('Select which dataset the submissions should be linked to') + ->createOptionForm(DatasetResource::getCreateFormFields()) + ->createOptionModalHeading("Create New Dataset") + ])]; + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('title'), + Tables\Columns\ViewColumn::make('required_fixed_media_count') + ->label('Fixed Media') + ->view('filament.tables.columns.required-fixed-media-count'), + Tables\Columns\ViewColumn::make('required_data_media_count') + ->label('Datasets') + ->view('filament.tables.columns.required-data-media-count'), + Tables\Columns\CheckboxColumn::make('available') + ->label('Available for use?') + ]) + ->filters([ + // + ]) + ->actions([ + Tables\Actions\ViewAction::make(), + Tables\Actions\EditAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } + + public static function infoList(Infolist $infolist): Infolist + { + return $infolist + ->schema([ + Section::make('Xls File') + ->schema([ + TextEntry::make('title'), + TextEntry::make('xlsfile_name') + ->url(fn(?XlsformTemplate $record): string => $record?->getFirstMediaUrl('xlsform_file')), + IconEntry::make('available') + ->label('Available to Platform users?') + ->icon(fn(bool $state): string => match($state) { + false => 'heroicon-o-no-symbol', + true => 'heroicon-o-check-circle', + }) + ]) + ->columns([ + 'lg' => 3, + 'md' => 2, + 'sm' => 1 + ]), + RepeatableEntry::make('requiredFixedMedia') + ->schema([ + TextEntry::make('name') + ->url(fn(?RequiredMedia $record): string => $record->getFirstMediaUrl()), + TextEntry::make('type'), + IconEntry::make('status') + ->icon(fn(int $state): string => match ($state) { + 1 => 'heroicon-o-check-circle', + 0 => 'heroicon-o-x-circle', + }) + ->color(fn(int $state): string => match ($state) { + 1 => 'success', + 0 => 'gray', + }), + ]) + ->columns([ + 'lg' => 3, + 'md' => 2, + 'sm' => 1 + ]), + + RepeatableEntry::make('requiredDataMedia') + ->schema([ + TextEntry::make('name') + ->url(fn(?RequiredMedia $record): string => $record->getFirstMediaUrl()), + TextEntry::make('full_type'), + IconEntry::make('status') + ->icon(fn(int $state): string => match ($state) { + 1 => 'heroicon-o-check-circle', + 0 => 'heroicon-o-x-circle', + }) + ->color(fn(int $state): string => match ($state) { + 1 => 'success', + 0 => 'gray', + }), + ])->columns([ + 'lg' => 3, + 'md' => 2, + 'sm' => 1 + ]), + + + Section::make('Main Survey') + ->columns(2) + ->schema([ + RepeatableEntry::make('schema') + ->label('List of variables in the main survey') + ->schema([ + TextEntry::make('name')->hiddenLabel(), + TextEntry::make('type')->hiddenLabel(), + ]) + ->visible(fn(?XlsformTemplate $record): bool => $record->rootSection->schema->count() < 5), + + ViewEntry::make('schema') + ->view('filament.infolists.components.xlsform-section-schema-modal-link') + ->registerActions([ + \Filament\Infolists\Components\Actions\Action::make('viewSchema') + ->label('View variable list') + ->icon('heroicon-o-eye') + ->form(function (?XlsformTemplate $record) { + return [ + TableRepeater::make('schema') + ->label('List of variables in the main survey') + ->deletable(false) + ->reorderable(false) + ->addable(false) + ->schema([ + Forms\Components\TextInput::make('name')->disabled()->hiddenLabel(), + Forms\Components\TextInput::make('type')->disabled()->hiddenLabel(), + ]), + ]; + }) + ->fillForm(function (?XlsformTemplate $record): array { + return [ + 'schema' => $record->rootSection->schema, + ]; + }) + ->modalSubmitAction(false) + ->modalCancelActionLabel('Close') + ]) + ->visible(fn(?XlsformTemplate $record): bool => $record->rootSection->schema->count() >= 5), + + TextEntry::make('rootSection.dataset.name')->label('Submission data is added to:') + ->url(fn(?XlsformTemplate $record): string => DatasetResource::getUrl('view', ['record' => $record->rootSection->dataset_id])), + + ]), + + + Section::make('Repeat Groups') + ->schema([ + RepeatableEntry::make('repeatingSections') + ->columns([ + 'lg' => 3, + 'md' => 2, + 'sm' => 1 + ]) + ->hiddenLabel() + ->schema(function ($state) { + return [ + TextEntry::make('structure_item')->label('Repeat Name'), + + ViewEntry::make('schema') + ->view('filament.forms.components.xlsform-section-schema-modal-link') + ->registerActions([ + \Filament\Infolists\Components\Actions\Action::make('viewSchema') + ->label('View variable list') + ->icon('heroicon-o-eye') + ->form(function (?XlsformTemplateSection $record) { + return [ + TableRepeater::make('schema') + ->label('List of variables in the repeat group') + ->deletable(false) + ->reorderable(false) + ->addable(false) + ->schema([ + Forms\Components\TextInput::make('name')->disabled()->hiddenLabel(), + Forms\Components\TextInput::make('type')->disabled()->hiddenLabel(), + ]), + ]; + }) + ->fillForm(fn(?XlsformTemplateSection $record): array => [ + 'schema' => $record->schema, + ]) + ->modalSubmitAction(false) + ->modalCancelActionLabel('Close') + ]), + + TextEntry::make('dataset.name')->label('Target Dataset') + ]; + }) + + ]) + ->visible(fn(?XlsformTemplate $record): bool => $record->repeatingSections->count() > 0), + + + Section::make('Draft Testing') + ->columns(2) + ->schema([ + + // show reminder text + TextEntry::make('reminder') + ->label('Please be remindeed that the form is only a draft. The ODK submissions sent to it will not be kept. It may not work well until you have added example csv files to any required datasets.'), + + // show QR code of ODK form draft version + ViewEntry::make('qr_code') + ->label('Scan the QR code below in ODK Collect to view the test form.') + ->view('filament.infolists.entries.draft-testing-qr-code'), + + // open URL in browser new tab + TextEntry::make('enketo_draft_url')->label('Click below link to view ODK form in browser') + ->url(fn(?XlsformTemplate $record): string => $record->enketo_draft_url) + ->openUrlInNewTab(), + + ]) + + ]); + } + + public static function getRelations(): array + { + return [ + + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListXlsformTemplates::route('/'), + 'create' => Pages\CreateXlsformTemplate::route('/create'), + 'edit' => Pages\EditXlsformTemplate::route('/{record}/edit'), + 'view' => Pages\ViewXlsformTemplate::route('/{record}'), + ]; + } +} diff --git a/src/Filament/Resources/XlsformTemplateResource/Pages/CreateXlsformTemplate.php b/src/Filament/Resources/XlsformTemplateResource/Pages/CreateXlsformTemplate.php new file mode 100644 index 0000000..14acf5a --- /dev/null +++ b/src/Filament/Resources/XlsformTemplateResource/Pages/CreateXlsformTemplate.php @@ -0,0 +1,103 @@ +description('Upload your XLSForm file and give it a title') + ->schema( + XlsformTemplateResource::getCreateFields(), + ) + ->afterValidation(function(Get $get) { + + $xlsformTemplate = XlsformTemplate::create([ + 'title' => $get('title'), + ]); + + $files = $get('xlsfile'); + + $xlsformTemplate->addMedia(collect($files)->first())->toMediaCollection('xlsform_file'); + + // this was being triggered on afterCreate. Call it here instead/as well. + $this->processRecord($xlsformTemplate); + + return redirect($this->getResource()::getUrl('edit', ['record' => $xlsformTemplate])); + + }), + + Step::make('2. Add Media Files') + ->description('Add any static media required by the form') + ->schema([]), + Step::make('3. Link Required Datasets') + ->description('Add / link external datasets for lookup tables') + ->schema([]), + Step::make('4. Review Xlsform Structure') + ->description('How should the collected data be handled?') + ->schema([]), + ]; + } + + + /** + * @throws RequestException + * @throws BindingResolutionException + */ + protected function afterCreate(): void + { + $this->processRecord($this->record); + } + + /** + * @throws RequestException + * @throws BindingResolutionException + */ + protected function processRecord(XlsformTemplate $record) + { + $odkLinkService = app()->make(OdkLinkService::class); + + $record->owner()->associate(Platform::first()); + $record->saveQuietly(); + + // update form title in xlsfile to match user-given title + UpdateXlsformTitleInFile::dispatchSync($record); + + $record->refresh(); + $record->deployDraft($odkLinkService); + $record->getRequiredMedia($odkLinkService); + + $record->extractSections(); + + return $record; + } + + +} diff --git a/src/Filament/Resources/XlsformTemplateResource/Pages/EditXlsformTemplate.php b/src/Filament/Resources/XlsformTemplateResource/Pages/EditXlsformTemplate.php new file mode 100644 index 0000000..ebbe09c --- /dev/null +++ b/src/Filament/Resources/XlsformTemplateResource/Pages/EditXlsformTemplate.php @@ -0,0 +1,70 @@ +title; + } + + public function getSteps(): array + { + return [ + Step::make('1. Xlsform') + ->description('Upload your XLSForm file and give it a title') + ->schema( + XlsformTemplateResource::getCreateFields(), + ), + Step::make('2. Add Media Files') + ->description('Add any static media required by the form') + ->schema( + XlsformTemplateResource::getStaticMediaFields(), + ), + Step::make('3. Link Required Datasets') + ->description('Add / link external datasets for lookup tables') + ->schema( + XlsformTemplateResource::getDatasetMediaFields(), + ), + Step::make('4. Review Xlsform Structure') + ->description('How should the collected data be handled?') + ->schema(XlsformTemplateResource::getXlsformSectionFields()), + + ]; + } + + public function getStartStep(): int + { + return 2; + } + + public function hasSkippableSteps(): bool + { + return true; + } + + protected function getHeaderActions(): array + { + return [ + Actions\ViewAction::make(), + Actions\DeleteAction::make(), + ]; + } + + protected function getRedirectUrl(): ?string + { + return $this->getResource()::getUrl('index'); + } +} diff --git a/src/Filament/Resources/XlsformTemplateResource/Pages/ListXlsformTemplates.php b/src/Filament/Resources/XlsformTemplateResource/Pages/ListXlsformTemplates.php new file mode 100644 index 0000000..a1e1a80 --- /dev/null +++ b/src/Filament/Resources/XlsformTemplateResource/Pages/ListXlsformTemplates.php @@ -0,0 +1,19 @@ +title; + } + + protected function getHeaderActions(): array + { + return [ + Actions\EditAction::make(), + Actions\DeleteAction::make(), + ]; + } +} diff --git a/src/FilamentOdkLink.php b/src/FilamentOdkLink.php new file mode 100644 index 0000000..ea7b4d2 --- /dev/null +++ b/src/FilamentOdkLink.php @@ -0,0 +1,7 @@ +publishConfigFile() ->publishMigrations() ->askToRunMigrations() - ->askToStarRepoOnGitHub(':vendor_slug/:package_slug'); + ->askToStarRepoOnGitHub('stats4sd/filament-odk-link'); }); $configFileName = $package->shortName(); @@ -82,18 +82,18 @@ public function packageBooted(): void if (app()->runningInConsole()) { foreach (app(Filesystem::class)->files(__DIR__ . '/../stubs/') as $file) { $this->publishes([ - $file->getRealPath() => base_path("stubs/skeleton/{$file->getFilename()}"), - ], 'skeleton-stubs'); + $file->getRealPath() => base_path("stubs/filament-odk-link/{$file->getFilename()}"), + ], 'filament-odk-link-stubs'); } } // Testing - Testable::mixin(new TestsSkeleton()); + Testable::mixin(new TestsFilamentOdkLink()); } protected function getAssetPackageName(): ?string { - return ':vendor_slug/:package_slug'; + return 'stats4sd/filament-odk-link'; } /** @@ -102,9 +102,9 @@ protected function getAssetPackageName(): ?string protected function getAssets(): array { return [ - // AlpineComponent::make('skeleton', __DIR__ . '/../resources/dist/components/skeleton.js'), - Css::make('skeleton-styles', __DIR__ . '/../resources/dist/skeleton.css'), - Js::make('skeleton-scripts', __DIR__ . '/../resources/dist/skeleton.js'), + // AlpineComponent::make('filament-odk-link', __DIR__ . '/../resources/dist/components/filament-odk-link.js'), + Css::make('filament-odk-link-styles', __DIR__ . '/../resources/dist/filament-odk-link.css'), + Js::make('filament-odk-link-scripts', __DIR__ . '/../resources/dist/filament-odk-link.js'), ]; } @@ -114,7 +114,7 @@ protected function getAssets(): array protected function getCommands(): array { return [ - SkeletonCommand::class, + FilamentOdkLinkCommand::class, ]; } @@ -148,7 +148,7 @@ protected function getScriptData(): array protected function getMigrations(): array { return [ - 'create_skeleton_table', + 'create_filament-odk-link_table', ]; } } diff --git a/src/Jobs/UpdateXlsformTitleInFile.php b/src/Jobs/UpdateXlsformTitleInFile.php new file mode 100644 index 0000000..3b6553f --- /dev/null +++ b/src/Jobs/UpdateXlsformTitleInFile.php @@ -0,0 +1,107 @@ +path($this->xlsform->xlsfile); + $spreadsheet = IOFactory::load($filePath); + + $worksheet = $spreadsheet->getSheetByName('settings'); + + if (!$worksheet) { + abort(500, "There is no settings sheet for this XLS Form"); + } + + $titleUpdated = false; + $idUpdated = false; + + // find the `form_id` entry and update: + foreach ($worksheet->getRowIterator() as $row) { + $cellIterator = $row->getCellIterator(); + + $cellIterator->setIterateOnlyExistingCells(true); + + foreach ($cellIterator as $cell) { + if ($cell->getValue() === "form_id" || $cell->getValue() === "id_string") { + + $coordinates = $cell->getCoordinate(); + + // if the form is already deployed, we must use the existing form_id on ODK: + $formId = $this->xlsform->odk_id ?? Str::slug($this->xlsform->title); + + // assume that the headers are on row < 10 and column < AA + $coordinates = str_split($coordinates); + $newCoordinates = $coordinates[0] . $coordinates[1]+1; + $worksheet->setCellValue($newCoordinates, $formId); + $idUpdated = true; + if ($titleUpdated) { + break; + } + } + + if ($cell->getValue() === "form_title") { + + $coordinates = $cell->getCoordinate(); + + // assume that the headers are on row < 10 and column < AA + $coordinates = str_split($coordinates); + $newCoordinates = $coordinates[0] . $coordinates[1]+1; + + $worksheet->setCellValue($newCoordinates, $this->xlsform->title); + + + $titleUpdated = true; + if ($idUpdated) { + break; + } + } + } + + if ($titleUpdated) { + break; + } + } + + + $writer = IOFactory::createWriter($spreadsheet, 'Xlsx'); + $writer->save($filePath); + + + + } +} diff --git a/src/Mail/TeamManagement/InviteMember.php b/src/Mail/TeamManagement/InviteMember.php new file mode 100644 index 0000000..fc4a892 --- /dev/null +++ b/src/Mail/TeamManagement/InviteMember.php @@ -0,0 +1,38 @@ +invite = $invite; + } + + /** + * Build the message. + * + * @return $this + */ + public function build() + { + return $this->from(config('mail.from.address')) + ->subject(config('app.name'). ': Invitation To Join Team ' . $this->invite->team->name) + ->markdown('team-management::emails.invite'); + } +} diff --git a/src/Mail/TeamManagement/RoleInviteMember.php b/src/Mail/TeamManagement/RoleInviteMember.php new file mode 100644 index 0000000..b5a94fc --- /dev/null +++ b/src/Mail/TeamManagement/RoleInviteMember.php @@ -0,0 +1,37 @@ +invite = $invite; + } + + /** + * Build the message. + * + * @return $this + */ + public function build() + { + return $this->from(config('mail.from.address')) + ->subject(config('app.name'). ': Invitation To Join with the ' . $this->invite->role->name . ' User Role') + ->markdown('team-management::emails.role_invite'); + } +} diff --git a/src/Models/OdkLink/AppUser.php b/src/Models/OdkLink/AppUser.php new file mode 100644 index 0000000..8626395 --- /dev/null +++ b/src/Models/OdkLink/AppUser.php @@ -0,0 +1,53 @@ +belongsTo(OdkProject::class); + } + + public function xlsforms(): BelongsToMany + { + return $this->belongsToMany(Xlsform::class, 'app_user_assignments'); + } + + /** + * Method to retrieve the encoded settings to create a QR code that allows access to the entire project. + * @throws JsonException + */ + public function getQrCodeStringAttribute(): ?string + { + $settings = [ + "general" => [ + "server_url" => 'https://kc.kobotoolbox.org', + "form_update_mode" => "match_exactly", + "username" => "crown_agents_demo", + "password" => "zmk9kqu-YXV*vqn2npa", + ], + "project" => ["name" => "Crown Agents Demo Project"], + "admin" => ["automatic_update" => true], + ]; + + + $json = json_encode($settings, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + + return base64_encode( + zlib_encode( + $json, + ZLIB_ENCODING_DEFLATE + ) + ); + } +} diff --git a/src/Models/OdkLink/Dataset.php b/src/Models/OdkLink/Dataset.php new file mode 100644 index 0000000..1c73aa0 --- /dev/null +++ b/src/Models/OdkLink/Dataset.php @@ -0,0 +1,65 @@ +hasMany(OdkDataset::class); + } + + public function variables(): HasMany + { + return $this->hasMany(DatasetVariable::class); + } + + // A dataset may hold data collected from multiple xlsforms. Xlsform sections table acts as the "pivot" table. + public function xlsformTemplateSections(): HasMany + { + return $this->hasMany(XlsformTemplateSection::class); + } + + public function xlsformTemplateSources(): BelongsToMany + { + return $this->belongsToMany(XlsformTemplate::class, 'xlsform_template_sections') + ->withPivot([ + 'structure_item', + 'is_repeat', + 'schema', + ]) + ->using(XlsformTemplateSection::class); + } + + // A dataset may be used as a source for xlsformtemplate lookup data + // Using the required_media as a pivot table + public function requiredMedia(): HasMany + { + return $this->hasMany(RequiredMedia::class); + } + + // xlsform templates that use this dataset as a source + public function xlsformTemplates(): BelongsToMany + { + return $this->belongsToMany(XlsformTemplate::class, 'required_media') + ->withPivot([ + 'name', + 'type', + 'is_static', + 'exists_on_odk', + ]) + ->using(RequiredMedia::class); + } + + +} diff --git a/src/Models/OdkLink/DatasetVariable.php b/src/Models/OdkLink/DatasetVariable.php new file mode 100644 index 0000000..1f178b2 --- /dev/null +++ b/src/Models/OdkLink/DatasetVariable.php @@ -0,0 +1,19 @@ +belongsTo(Dataset::class); + } +} diff --git a/src/Models/OdkLink/Entity.php b/src/Models/OdkLink/Entity.php new file mode 100644 index 0000000..f7d1dd9 --- /dev/null +++ b/src/Models/OdkLink/Entity.php @@ -0,0 +1,75 @@ +belongsTo(Dataset::class); + } + + public function owner(): MorphTo + { + return $this->morphTo(); + } + + public function model(): MorphTo + { + return $this->morphTo(); + } + + public function values(): HasMany + { + return $this->hasMany(EntityValue::class, 'entity_id'); + } + + public function datasetVariables(): BelongsToMany + { + return $this->belongsToMany(DatasetVariable::class, 'entity_values') + ->using(EntityValue::class) + ->withPivot('value'); + } + + + /* + * Override getAttribute() to check the entity_values table first. + */ + public function getAttribute($key) + { + + // if the default getAttribute() returns something, great! Do that + if ($value = parent::getAttribute($key)) { + return $value; + } + + + /* + * If the requested attribute is in the dataset variables list, check the values() relationship + */ + if ($this->getVariableList()->contains($key)) { + return $this->values()->whereHas('datasetVariable', function (Builder $query) use ($key) { + $query->where('dataset_variables.name', $key); + })->first()?->value; + } + + /* + * Otherwise, attempt to defer to the linked model: + */ + return $this->model->getAttribute($key); + + } + + + +} diff --git a/src/Models/OdkLink/EntityValue.php b/src/Models/OdkLink/EntityValue.php new file mode 100644 index 0000000..19298f2 --- /dev/null +++ b/src/Models/OdkLink/EntityValue.php @@ -0,0 +1,24 @@ +belongsTo(Dataset::class); + } + + public function odkProject(): BelongsTo + { + return $this->belongsTo(OdkProject::class); + } +} diff --git a/src/Models/OdkLink/Interfaces/WithXlsFormDrafts.php b/src/Models/OdkLink/Interfaces/WithXlsFormDrafts.php new file mode 100644 index 0000000..2894e0a --- /dev/null +++ b/src/Models/OdkLink/Interfaces/WithXlsFormDrafts.php @@ -0,0 +1,27 @@ +belongsTo(Dataset::class); + } + + public function odkProject(): BelongsTo + { + return $this->belongsTo(OdkProject::class); + } +} diff --git a/src/Models/OdkLink/OdkProject.php b/src/Models/OdkLink/OdkProject.php new file mode 100644 index 0000000..4b38526 --- /dev/null +++ b/src/Models/OdkLink/OdkProject.php @@ -0,0 +1,46 @@ +morphTo(); // can be linked to any Model with the HasXlsforms trait. + } + + public function appUsers(): HasMany + { + return $this->hasMany(AppUser::class); + } + + // add this method because it will be called when xlsform->toArray() is called + public function odkUrl(): Attribute + { + return new Attribute( + get: fn(): ?string => config('odk-link.odk.url') . "/#/projects/" . $this->id, + ); + } + + + // TODO: is this redundant? It's certainly not normalised SQL, as in theory we can get to Xlsforms via the owner, but we don't know the model type of the owner, so it's easier to add odk_project_id to the xlsforms table and add this relationship. + public function xlsforms(): HasMany + { + return $this->hasMany(Xlsform::class); + } +} diff --git a/src/Models/OdkLink/Platform.php b/src/Models/OdkLink/Platform.php new file mode 100644 index 0000000..424e79d --- /dev/null +++ b/src/Models/OdkLink/Platform.php @@ -0,0 +1,27 @@ + config('app.name', 'Laravel Platform') . ' Platform.php' . $this->id, + ); + } +} diff --git a/src/Models/OdkLink/RequiredMedia.php b/src/Models/OdkLink/RequiredMedia.php new file mode 100644 index 0000000..c6fec64 --- /dev/null +++ b/src/Models/OdkLink/RequiredMedia.php @@ -0,0 +1,69 @@ + 'boolean', + ]; + + protected static function booted() + { + + // when deleting, also delete any attached media; + static::deleting(function ($requiredMedia) { + $requiredMedia->getMedia() + ->each(fn($media) => $requiredMedia->deleteMedia($media)); + }); + + + // when updating, update the related xlsform template to set draft_needs_updating to true (to ensure the updated media is pushed to ODK Central for testing + static::saved(function (RequiredMedia $requiredMedia) { + $requiredMedia->xlsformTemplate->updateQuietly( + ['draft_needs_updating' => true,] + ); + + }); + } + + public function status(): Attribute + { + return new Attribute( + get: fn(): string => $this->dataset_id || $this->hasMedia() ? 1 : 0, + ); + } + + public function fullType(): Attribute + { + return new Attribute( + get: fn(): string => $this->is_static ? $this->type : 'dataset', + ); + } + + public function xlsformTemplate(): BelongsTo + { + return $this->belongsTo(XlsformTemplate::class); + } + + public function dataset(): BelongsTo + { + return $this->belongsTo(Dataset::class); + } + + // maybe need to get imageUrl (for media attachments) and/or dataset attachment... + + +} diff --git a/src/Models/OdkLink/Submission.php b/src/Models/OdkLink/Submission.php new file mode 100644 index 0000000..f5079f7 --- /dev/null +++ b/src/Models/OdkLink/Submission.php @@ -0,0 +1,79 @@ + 'array', + 'errors' => 'array', + 'entries' => 'array', + ]; + + protected static function booted() + { + static::addGlobalScope('owned', function (Builder $query) { + if (Auth::check() && !Auth::user()->hasRole(config('odk-link.roles.xlsform-admin'))) { + $query->where(function (Builder $query) { + $query->whereHas('xlsformVersion', function (Builder $query) { + $query->whereHas('xlsform', function (Builder $query) { + $query->whereHas('owner', function (Builder $query) { + + //if xlsforms are owned by a user, return the user's forms directly. + if (is_a($query->getModel(), User::class)) { + $query->where('users.id', Auth::id()); + } else { + // is the xlsform owned by a team/group that the logged-in user is linked to? + $query->whereHas('users', function ($query) { + $query->where('users.id', Auth::id()); + }); + } + }); + }); + }); + }); + } + }); + } + + // $this->entries is an array of every Model entry created as a result of processing this submission. + // This helper function makes it easy to update this array. + public function addEntry(string $model, array $ids): void + { + $value = $this->entries; + + if ($value && array_key_exists($model, $value)) { + $value[$model] = array_merge($value[$model], $ids); + } else { + $value[$model] = $ids; + } + + $this->entries = $value; + $this->save(); + } + + public function xlsformVersion(): BelongsTo + { + return $this->belongsTo(XlsformVersion::class); + } + + public function xlsformTitle(): Attribute + { + return new Attribute( + get: fn() => $this->xlsformVersion->xlsform->title, + ); + } + +} diff --git a/src/Models/OdkLink/Traits/HasXlsFormDrafts.php b/src/Models/OdkLink/Traits/HasXlsFormDrafts.php new file mode 100644 index 0000000..6cb44ce --- /dev/null +++ b/src/Models/OdkLink/Traits/HasXlsFormDrafts.php @@ -0,0 +1,64 @@ +createDraftForm($this); + + $this->updateQuietly([ + 'odk_id' => $odkXlsFormDetails['xmlFormId'], + 'odk_draft_token' => $odkXlsFormDetails['draftToken'], + 'odk_version_id' => $odkXlsFormDetails['version'], + 'has_draft' => true, + 'enketo_draft_id' => $odkXlsFormDetails['enketoId'], + ]); + } + + /** + * Method to retrieve the encoded settings for the current draft version on ODK Central + * @throws JsonException + */ + public function getDraftQrCodeStringAttribute(): ?string + { + if (!$this->has_draft) { + return null; + } + + $settings = [ + "general" => [ + "server_url" => config('odk-link.odk.base_endpoint') . "/test/{$this->odk_draft_token}/projects/{$this->owner->odkProject->id}/forms/{$this->odk_id}/draft", + "form_update_mode" => "match_exactly", + ], + "project" => ["name" => "(DRAFT) " . $this->title, "icon" => "📝"], + "admin" => ["automatic_update" => true], + ]; + + $json = json_encode($settings, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + + return base64_encode(zlib_encode($json, ZLIB_ENCODING_DEFLATE)); + + } + + public function updateDraftFormDetails(OdkLinkService $odkLinkService): void + { + $updated = $odkLinkService->getDraftFormDetails($this); + + $this->update([ + 'odk_draft_token' => $updated['draftToken'], + 'enketo_draft_id' => $updated['enketoId'], + ]); + } + +} diff --git a/src/Models/OdkLink/Traits/HasXlsForms.php b/src/Models/OdkLink/Traits/HasXlsForms.php new file mode 100644 index 0000000..7f9c3d3 --- /dev/null +++ b/src/Models/OdkLink/Traits/HasXlsForms.php @@ -0,0 +1,77 @@ +make(OdkLinkService::class); + + // when the model is created; automatically create an associated project on ODK Central; + static::created(function ($owner) use ($odkLinkService) { + $owner->createLinkedOdkProject($odkLinkService, $owner); + }); + } + + // Used as the human-readable label for the owners of forms. Uses the same variable name that some Laravel Backpack fields expect (e.g. Relationship) + // Xls Form titles are in the format `$owner->$nameAttribute . '-' . $xlsform->title` + public string $identifiableAttribute = 'name'; + + public function xlsforms(): MorphMany + { + return $this->morphMany(Xlsform::class, 'owner'); + } + + + // Private templates are owned by a single form owner. + // All owners have access to all public templates (templates where available = 1) + public function xlsformTemplates(): MorphMany + { + return $this->morphMany(XlsformTemplate::class, 'owner'); + } + + public function odkProject(): MorphOne + { + return $this->morphOne(OdkProject::class, 'owner'); + } + + + /** + * @param mixed $odkLinkService + * @param $owner + * @return void + */ + public function createLinkedOdkProject(OdkLinkService $odkLinkService): void + { + $odkProjectInfo = $odkLinkService->createProject($this->name); + $odkProject = $this->odkProject()->create([ + 'id' => $odkProjectInfo['id'], + 'name' => $odkProjectInfo['name'], + 'archived' => $odkProjectInfo['archived'], + ]); + + // create an app user + assign to all forms in the project by giving them the admin role; + $odkAppUserInfo = $odkLinkService->createProjectAppUser($odkProject); + + $odkProject->appUsers()->create([ + 'id' => $odkAppUserInfo['id'], + 'display_name' => $odkAppUserInfo['displayName'], + 'type' => 'field_key', // legacy term for "App User" in ODK Central; + 'token' => $odkAppUserInfo['token'], // the token required to generate the ODK QR Code; + 'can_access_all_forms' => true, + ]); + } + + +} diff --git a/src/Models/OdkLink/Traits/PublishesToOdkCentral.php b/src/Models/OdkLink/Traits/PublishesToOdkCentral.php new file mode 100644 index 0000000..d165048 --- /dev/null +++ b/src/Models/OdkLink/Traits/PublishesToOdkCentral.php @@ -0,0 +1,49 @@ + $this->getFirstMediaPath('xlsform_file'), + ); + } + + public function xlsfileName(): Attribute + { + return new Attribute( + get: fn(): ?string => $this->getFirstMedia('xlsform_file')?->file_name, + ); + } + + public function enketoDraftUrl(): Attribute + { + return new Attribute( + get: function () { + // if there is no enketo id in the database, retrieve it from ODK Central + if (!$this->enketo_draft_id || Str::endsWith($this->enketo_draft_id, '/-/')) { + $this->updateDraftFormDetails(app()->make(OdkLinkService::class)); + } + + + return config('odk-link.odk.url') . '/-/' . $this->enketo_draft_id; + + + }, + ); + } + + public function deleteFromOdkCentral(OdkLinkService $odkLinkService): void + { + $odkLinkService->deleteForm($this); + } + + +} diff --git a/src/Models/OdkLink/Xlsform.php b/src/Models/OdkLink/Xlsform.php new file mode 100644 index 0000000..50f6e64 --- /dev/null +++ b/src/Models/OdkLink/Xlsform.php @@ -0,0 +1,161 @@ +xlsfile) { + $xlsform->updateXlsfileFromTemplate(); + } + + // if the odk_project is not set, set it based on the given owner: + $xlsform->odk_project_id = $xlsform->owner->odkProject->id; + $xlsform->saveQuietly(); + }); + + static::deleting(static function (Xlsform $xlsform) { + $odkLinkService = app()->make(OdkLinkService::class); + $xlsform->deleteFromOdkCentral($odkLinkService); + }); + } + + public function registerMediaCollections(): void + { + $this->addMediaCollection('xlsform_file') + ->singleFile() + ->useDisk(config('odk-link.storage.xlsforms')); + + $this->addMediaCollection('attached_media') + ->useDisk(config('odk-link.storage.xlsforms')); + } + + + // ****************** COMPUTED ATTRIBUTES ************************ + + // Get an xlsformId string that is both human-readable and guaranteed to be unique within the platform + public function xlsformId(): Attribute + { + return new Attribute( + get: fn(): string => str($this->title)->slug() . '_' . $this->id, + ); + } + + public function ownedByName(): Attribute + { + return new Attribute( + get: fn(): string => $this->owner->{$this->getOwnerIdentifierAttributeName()} ?? '', + ); + } + + public function currentVersion(): Attribute + { + return new Attribute( + get: fn(): string => $this->xlsformVersions()->latest()->first()?->version ?? '', + ); + } + + public function status(): Attribute + { + return new Attribute( + get: fn() => $this->is_active ? 'LIVE' : ($this->odk_draft_token ? 'DRAFT' : 'NOT DEPLOYED'), + ); + } + + // ****************** RELATIONSHIPS ************************ + + public function owner(): MorphTo + { + return $this->morphTo(); + } + + public function xlsformTemplate(): BelongsTo + { + return $this->belongsTo(XlsformTemplate::class); + } + + public function xlsformVersions(): HasMany + { + return $this->hasMany(XlsformVersion::class); + } + + public function submissions(): HasManyThrough + { + return $this->hasManyThrough(Submission::class, XlsformVersion::class); + } + + // ***** RELATIONSHIPS VIA XLSFORM TEMPLATE ***** + + public function requiredMedia(): HasMany + { + return $this->xlsformTemplate->requiredMedia(); + } + + public function attachedFixedMedia(): HasMany + { + return $this->xlsformTemplate->attachedFixedMedia(); + } + + public function attachedDataMedia(): HasMany + { + return $this->xlsformTemplate->attachedDataMedia(); + } + + + // *********************** FUNCTIONS **************************** + + /** + * @throws FileDoesNotExist + * @throws FileIsTooBig + */ + public function updateXlsfileFromTemplate(): void + { + // copy media item from template: + $this->xlsformTemplate->getFirstMedia('xlsform_file')?->copy($this, 'xlsform_file'); + + $this->saveQuietly(); + UpdateXlsformTitleInFile::dispatchSync($this); + } + + public function getOdkLinkAttribute(): ?string + { + $appends = !$this->is_active ? '/draft' : ''; + + return config('odk-link.odk.url') . "/#/projects/" . $this->owner->odkProject->id . '/forms/' . $this->odk_id . $appends; + } + + +} diff --git a/src/Models/OdkLink/XlsformTemplate.php b/src/Models/OdkLink/XlsformTemplate.php new file mode 100644 index 0000000..1c89196 --- /dev/null +++ b/src/Models/OdkLink/XlsformTemplate.php @@ -0,0 +1,236 @@ + 'collection', + ]; + + protected static function booted() + { + static::deleting(static function (XlsformTemplate $xlsform) { + $odkLinkService = app()->make(OdkLinkService::class); + $xlsform->deleteFromOdkCentral($odkLinkService); + }); + } + + // setup media library collections: + // - xlsformfile + // - attached media + + public function registerMediaCollections(): void + { + $this->addMediaCollection('xlsform_file') + ->singleFile() + ->useDisk(config('odk-link.storage.xlsforms')); + + $this->addMediaCollection('attached_media') + ->useDisk(config('odk-link.storage.xlsforms')); + } + + // ****************** COMPUTED ATTRIBUTES ************************ + + + // ****************** RELATIONSHIPS ************************ + + public function submissions(): HasManyThrough + { + return $this->hasManyThrough(Submission::class, Xlsform::class); + } + + public function xlsforms(): HasMany + { + return $this->hasMany(Xlsform::class); + } + + public function activeXlsforms(): HasMany + { + return $this->hasMany(Xlsform::class) + ->where('is_active', true); + } + + public function owner(): MorphTo + { + return $this->morphTo(); + } + + /** 1 entry created for each required item as given from ODK Central */ + public function requiredMedia(): HasMany + { + return $this->hasMany(RequiredMedia::class); + } + + /** filtered Required Media to only show media with type "image", "video" or "audio" */ + public function requiredFixedMedia(): HasMany + { + return $this->hasMany(RequiredMedia::class) + ->where('required_media.type', '!=', 'file'); + } + + public function requiredDataMedia(): HasMany + { + return $this->hasMany(RequiredMedia::class) + ->where('required_media.type', '=', 'file'); + } + + public function attachedFixedMedia(): HasMany + { + return $this->hasMany(RequiredMedia::class) + ->where('required_media.type', '!=', 'file') + ->whereHas('media'); + } + + public function attachedDataMedia(): HasMany + { + return $this->hasMany(RequiredMedia::class) + ->where('required_media.type', '=', 'file') + ->where(function (Builder $query) { + $query->whereHas('media') + ->orWhere('required_media.dataset_id', '!=', null); + }); + + } + + public function datasets(): BelongsToMany + { + return $this->belongsToMany(Dataset::class) + ->withPivot([ + 'is_root', + 'is_repeat', + 'structure_item' + ]); + } + + public function xlsformTemplateSections(): HasMany + { + return $this->hasMany(XlsformTemplateSection::class); + } + + public function repeatingSections(): HasMany + { + return $this->hasMany(XlsformTemplateSection::class) + ->where('is_repeat', true); + } + + public function rootSection(): HasOne + { + return $this->hasOne(XlsformTemplateSection::class) + ->where('structure_item', 'root'); + } + + + + // ****************** METHODS ************************ + + + // get required media from ODK Central and store in the database + public function getRequiredMedia(OdkLinkService $odkLinkService): void + { + $mediaItems = $odkLinkService->getRequiredMedia($this); + + foreach ($mediaItems as $mediaItem) { + $this->requiredMedia()->updateOrCreate([ + 'name' => $mediaItem['name'], + ], [ + 'type' => $mediaItem['type'], + 'exists_on_odk' => $mediaItem['exists'], + ]); + + } + + } + + // get link to form in ODK Central + public function getOdkLinkAttribute(): ?string + { + return config('odk-link.odk.url') . "/#/projects/" . $this->owner->odkProject->id . "/forms/" . $this->odk_id . "/draft"; + } + + + public function extractSections() + { + + //*** extract structure into 'sections' + + // create or find the 'root' section + $this->xlsformTemplateSections()->updateOrCreate([ + 'structure_item' => 'root', + ], [ + 'is_repeat' => false, + 'schema' => $this->schema->filter(fn($item) => $item['type'] !== 'structure' && $item['type'] !== 'repeat' && $item['path'] === "/{$item['name']}"), + ]); + + // create or find the repeat sections + $this->schema->filter(fn($item) => $item['type'] === 'repeat') + ->each(function ($item) { + $this->repeatingSections()->updateOrCreate([ + 'structure_item' => $item['name'], + ], [ + 'is_repeat' => true, + 'schema' => $this->schema->filter(fn($subItem) => Str::contains($subItem['path'], $item['path'] . '/') + && $subItem['path'] !== $item['path'] + && $subItem['type'] !== 'repeat' + ), + ]); + }); + + + // the above approach is fine unless there are nested repeats. Then, the inner repeat items will *also* be in the outer repeat schema. + // To counter this, after each repeat group is created, we filter out any items that are in an innter repeat: + + $this->repeatingSections->each(function (XlsformTemplateSection $section) { + $this->repeatingSections->each(function (XlsformTemplateSection $reviewSection) use ($section) { + + // don't compare the section to itself + if ($reviewSection->structure_item === $section->structure_item) { + return; + } + + // remove all items from the review section that have the same initial path as the $section. + + +// dump('Section x Seciton REveiw'); +// dump('Section: ' . $section); +// dump('Rewveiw Section: ' . $reviewSection); +// +// +// dump($reviewSection->schema); + $reviewSection->schema = $reviewSection->schema->filter(fn($item) => !Str::startsWith($item['path'], '/' . $reviewSection->structure_item . '/' . $section->structure_item . '/') + ); + + + $reviewSection->save(); + + }); + }); + +// dd('ok'); + + return $this->xlsformTemplateSections; + } +} diff --git a/src/Models/OdkLink/XlsformTemplateSection.php b/src/Models/OdkLink/XlsformTemplateSection.php new file mode 100644 index 0000000..5d7dc1c --- /dev/null +++ b/src/Models/OdkLink/XlsformTemplateSection.php @@ -0,0 +1,29 @@ + 'collection', + ]; + + public function xlsformTemplate(): BelongsTo + { + return $this->belongsTo(XlsformTemplate::class); + } + + public function dataset(): BelongsTo + { + return $this->belongsTo(Dataset::class); + } + + +} diff --git a/src/Models/OdkLink/XlsformVersion.php b/src/Models/OdkLink/XlsformVersion.php new file mode 100644 index 0000000..edee092 --- /dev/null +++ b/src/Models/OdkLink/XlsformVersion.php @@ -0,0 +1,60 @@ + 'array', + ]; + + + // **************** COMPUTED ATTRIBUTES *********************** + + // If no title is given, add a default title by combining the owner name and template title. + public function title(): Attribute + { + return new Attribute( + get: fn (): string => $this->team ? $this->team->name.' - '.$this->xlsform->title : '', + ); + } + + public function xlsfile(): Attribute + { + return new Attribute( + get: fn(): string => $this->getFirstMediaPath('xlsform_file'), + ); + } + + public function xlsfile_name(): Attribute + { + return new Attribute( + get: fn(): string => $this->getFirstMedia('xlsform_file')->file_name, + ); + } + + // ************ RELATIONSHIPS *************** + + public function xlsform(): BelongsTo + { + return $this->belongsTo(Xlsform::class); + } + + public function submissions(): HasMany + { + return $this->hasMany(Submission::class); + } +} diff --git a/src/Models/TeamManagement/RoleInvite.php b/src/Models/TeamManagement/RoleInvite.php new file mode 100644 index 0000000..060e4a5 --- /dev/null +++ b/src/Models/TeamManagement/RoleInvite.php @@ -0,0 +1,36 @@ + 'boolean', + ]; + + protected static function booted() + { + static::addGlobalScope('unconfirmed', function (Builder $builder) { + $builder->where('is_confirmed', false); + }); + } + + // *********** RELATIONSHIPS ************ // + public function inviter() + { + return $this->belongsTo(User::class, 'inviter_id'); + } + + public function role() + { + return $this->belongsTo(Role::class); + } +} diff --git a/src/Models/TeamManagement/Team.php b/src/Models/TeamManagement/Team.php new file mode 100644 index 0000000..7c5a14f --- /dev/null +++ b/src/Models/TeamManagement/Team.php @@ -0,0 +1,66 @@ +invites()->create([ + 'email' => $email, + 'inviter_id' => auth()->id(), + 'token' => Str::random(24), + ]); + + Mail::to($invite->email)->send(new InviteMember($invite)); + } + } + + // **************** RELATIONSHIPS ***************** // + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'team_members') + ->withPivot('is_admin'); + } + + public function admins(): BelongsToMany + { + return $this->belongsToMany(User::class, 'team_members') + ->withPivot('is_admin') + ->wherePivot('is_admin', 1); + } + + public function members(): BelongsToMany + { + return $this->belongsToMany(User::class, 'team_members') + ->withPivot('is_admin') + ->wherePivot('is_admin', 0); + } + + public function invites(): HasMany + { + return $this->hasMany(TeamInvite::class); + } + +} diff --git a/src/Models/TeamManagement/TeamInvite.php b/src/Models/TeamManagement/TeamInvite.php new file mode 100644 index 0000000..2b5cd09 --- /dev/null +++ b/src/Models/TeamManagement/TeamInvite.php @@ -0,0 +1,45 @@ + 'boolean', + ]; + + protected static function booted(): void + { + static::addGlobalScope('unconfirmed', function (Builder $builder) { + $builder->where('is_confirmed', false); + }); + } + + // *********** RELATIONSHIPS ************ // + public function inviter(): BelongsTo + { + return $this->belongsTo(User::class, 'inviter_id'); + } + + public function team(): BelongsTo + { + return $this->belongsTo(Team::class); + } + + // ************ METHODS ************ // + + public function confirm(): bool + { + $this->is_confirmed = 1; + $this->save(); + + return $this->is_confirmed; + } +} diff --git a/src/Models/TeamManagement/Traits/HasTeamMemberships.php b/src/Models/TeamManagement/Traits/HasTeamMemberships.php new file mode 100644 index 0000000..f3831de --- /dev/null +++ b/src/Models/TeamManagement/Traits/HasTeamMemberships.php @@ -0,0 +1,61 @@ +email)->get(); + + foreach ($invites as $invite) { + $member->teams()->syncWithoutDetaching($invite->team->id); + $invite->confirm(); + } + + // if the user was invited to one or more user roles, assign them to the role(s) + $roleInvites = RoleInvite::where('email', '=', $member->email)->get(); + + foreach ($roleInvites as $invite) { + $member->roles()->syncWithoutDetaching($invite->role->id); + $invite->confirm(); + } + }); + } + + public function teams(): BelongsToMany + { + return $this->belongsToMany(Team::class, 'team_members')->withPivot('is_admin'); + } + + public function teamInvitesSent(): HasMany + { + return $this->hasMany(TeamInvite::class, 'inviter_id'); + } + + public function roleInvitesSent(): HasMany + { + return $this->hasMany(RoleInvite::class, 'inviter_id'); + } + + +} diff --git a/src/Services/OdkLinkService.php b/src/Services/OdkLinkService.php new file mode 100644 index 0000000..ef79619 --- /dev/null +++ b/src/Services/OdkLinkService.php @@ -0,0 +1,623 @@ +addHours(20), function () { + + $response = Http::post("{$this->endpoint}/sessions", [ + "email" => config("odk-link.odk.username"), + "password" => config("odk-link.odk.password"), + ]) + ->throw() + ->json(); + + return $response['token']; + + }); + + } + + /** + * Creates a new project in ODK Central + * @param string $name + * @return array $projectInfo + * @throws RequestException + */ + public function createProject(string $name): array + { + $token = $this->authenticate(); + + // prepend platform identifier to project name; + $name = config('app.name') . ' -- ' . $name; + + return Http::withToken($token) + ->post("{$this->endpoint}/projects", [ + 'name' => $name + ]) + ->throw() + ->json(); + + } + + public function createProjectAppUser(OdkProject $odkProject): array + { + $token = $this->authenticate(); + + + // create new app-user + $userResponse = Http::withToken($token) + ->post("{$this->endpoint}/projects/{$odkProject->id}/app-users", [ + 'displayName' => 'All Forms - ' . $odkProject->owner->name . " - " . $odkProject->appUsers()->count() + 1, + ]) + ->throw() + ->json(); + + // assign user to all the forms in the project + Http::withToken($token) + ->post("{$this->endpoint}/projects/{$odkProject->id}/assignments/manager/{$userResponse['id']}") + ->throw() + ->json(); + + return $userResponse; + + } + + /** + * Updates a project name + * @param OdkProject $odkProject + * @param string $newName + * @return array $projectInfo + * @throws RequestException + */ + public function updateProject(OdkProject $odkProject, string $newName): array + { + $token = $this->authenticate(); + + return Http::withToken($token) + ->post("{$this->endpoint}/projects/$odkProject->id", [ + 'name' => $newName, + ]) + ->throw() + ->json(); + } + + /** + * Archives a project + * @param OdkProject $odkProject + * @return array $success + * @throws RequestException + */ + public function archiveProject(OdkProject $odkProject): array + { + $token = $this->authenticate(); + + return Http::withToken($token) + ->post("{$this->endpoint}/projects/$odkProject->id", [ + 'name' => $odkProject->name, + 'archived' => true, + ]) + ->throw() + ->json(); + } + + /** + * Creates a new (draft) form. + * If the form is not already deployed, it will create a new form instance on ODK Central. + * If the form is already deployed, it will push the current XLSfile as a new draft to the existing form. + * @param Xlsform $xlsform + * @return array $xlsformDetails + * @throws RequestException + */ + public function createDraftForm(Xlsform $xlsform): array + { + $token = $this->authenticate(); + + $file = file_get_contents(Storage::disk(config('odk-link.storage.xlsforms'))->path($xlsform->xlsfile)); + + $url = "{$this->endpoint}/projects/{$xlsform->owner->odkProject->id}/forms?ignoreWarnings=true&publish=false"; + + // if the form is already on ODK Central, post to /forms/{id}/draft endpoint. Otherwise, post to /forms endpoint to create an entirely new form. + if ($xlsform->odk_id) { + $url = "{$this->endpoint}/projects/{$xlsform->owner->odkProject->id}/forms/{$xlsform->odk_id}/draft?ignoreWarnings=true"; + } + + $response = Http::withToken($token) + ->withHeaders([ + 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'X-XlsForm-FormId-Fallback' => Str::slug($xlsform->title), + ]) + ->withBody($file, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + ->post($url) + ->throw() + ->json(); + + // when creating a new draft for an existing form, the full form details are not returned. In this case, the $xlsform record can remain unchanged + if (isset($response['xmlFormId'])) { + $xlsform->update(['odk_id' => $response['xmlFormId']]); + } + + // deploy media files + $this->uploadMediaFileAttachments($xlsform); + + + return $this->getDraftFormDetails($xlsform); + } + + /** + * Gets the draft form details for a given xlsform + * @param Xlsform $xlsform + * @return array + * @throws RequestException + */ + public function getDraftFormDetails(Xlsform $xlsform): array + { + $token = $this->authenticate(); + + return Http::withToken($token) + ->get("{$this->endpoint}/projects/{$xlsform->owner->odkProject->id}/forms/{$xlsform->odk_id}/draft") + ->throw() + ->json(); + } + + /** + * Uploads all media files for an XLSform to ODK Central - both static files and dyncsv files + * @param Xlsform $xlsform + * @return bool $success + * @throws RequestException + */ + public function uploadMediaFileAttachments(Xlsform $xlsform): bool + { + // static files + $files = $xlsform->xlsformTemplate->media; + + if ($files && count($files) > 0) { + + foreach ($files as $file) { + $this->uploadSingleMediaFile($xlsform, $file); + } + + } + // dynamic files + $csv_lookups = $xlsform->xlsformTemplate->csv_lookups; + + + if ($csv_lookups && count($csv_lookups) > 0) { + + foreach ($csv_lookups as $lookup) { + + $this->uploadSingleMediaFile( + $xlsform, + $this->createCsvLookupFile($xlsform, $lookup), + ); + + } + } + + return true; + + } + + /** + * Uploads a single media file to the given xlsform + * @param Xlsform $xlsform + * @param string $filePath + * @return array + * @throws RequestException + */ + public function uploadSingleMediaFile(Xlsform $xlsform, string $filePath): array + { + $token = $this->authenticate(); + $file = file_get_contents(Storage::disk(config('odk-link.storage.xlsforms'))->path($filePath)); + + $mimeType = mime_content_type(Storage::disk(config('odk-link.storage.xlsforms'))->path($filePath)); + $fileName = collect(explode("/", $filePath))->last(); + + try { + + return Http::withToken($token) + ->contentType($mimeType) + ->withBody($file, $mimeType) + ->post("{$this->endpoint}/projects/{$xlsform->owner->odkProject->id}/forms/{$xlsform->odk_id}/draft/attachments/{$fileName}") + ->throw() + ->json(); + } catch (RequestException $exception) { + if ($exception->getCode() === 404) { + abort(500, 'The file ' . $fileName . ' is not an expected file name for this ODK form template. Please review the form and check which media files are expected'); + } + } + } + + /** + * Publishes the current draft form so it is available for live data collection + * @param Xlsform $xlsform + * @return XlsformVersion $xlsformVersion + */ + public function publishForm(Xlsform $xlsform): XlsformVersion + { + + $token = $this->authenticate(); + +// // create a new version locally +// $version = 1; +// +// // if there is an existing version; increment the version number; +// if ($xlsform->xlsformVersions()->count() > 0) { +// $version = $xlsform->xlsformVersions()->orderBy('version', 'desc')->first()->version + 1; +// } + + Http::withToken($token) + ->post("{$this->endpoint}/projects/{$xlsform->owner->odkProject->id}/forms/{$xlsform->odk_id}/draft/publish?version=" . Carbon::now()->toDateTimeString()) + ->throw() + ->json(); + + // Get the version information; + $formDetails = Http::withToken($token) + ->get("{$this->endpoint}/projects/{$xlsform->owner->odkProject->id}/forms/{$xlsform->odk_id}") + ->throw() + ->json(); + + if ($formDetails['state'] !== 'open') { + $formDetails = $this->unArchiveForm($xlsform); + } + + + // TODO: move all of this into some form of XlsformVersion handler! + // deactivate all other versions; + $xlsform->xlsformVersions()->update([ + 'active' => false, + ]); + + $xlsformVersion = $this->createNewVersion($xlsform, $formDetails); + + $xlsform->update([ + 'has_draft' => false, + 'is_active' => true, + 'odk_version_id' => $xlsformVersion->version, + ]); + $xlsform->save(); + + return $xlsformVersion; + + } + + /** + * Archives a form to prevent further data collection + * @param Xlsform $xlsform + * @return array $xlsformDetails + */ + public function archiveForm(Xlsform $xlsform): array + { + $token = $this->authenticate(); + + $result = Http::withToken($token) + ->patch("{$this->endpoint}/projects/{$xlsform->owner->odkProject->id}/forms/{$xlsform->odk_id}", [ + 'state' => 'closed', + ]) + ->throw() + ->json(); + + $xlsform->update([ + 'is_active' => false, + ]); + + return $result; + + } + + /** + * Generates a lookup file for a specific xlsform. + * @param Xlsform $xlsform + * @param mixed $lookup + * @return void + */ + private function generateLookupFile(Xlsform $xlsform, mixed $lookup) + { + } + + /** + * Creates a new csv lookup file from the database; + * @param Xlsform $xlsform + * @param mixed $lookup + * @return string + */ + private function createCsvLookupFile(Xlsform $xlsform, mixed $lookup): string + { + + $filePath = 'xlsforms' . $xlsform->id . '/' . $lookup['csv_name'] . ".csv"; + + if ($lookup['per_owner'] === "1") { + $owner = $xlsform->owner; + } else { + $owner = null; + } + + + Excel::store( + new SqlViewExport($lookup['mysql_name'], $owner, $lookup['owner_foreign_key']), + $filePath, + config('odk-link.storage.xlsforms') + ); + + // If the csv file is used with "select_one_from_external_file" (or multiple) it must not have any enclosure characters: + if (isset($lookup['external_file']) && $lookup['external_file'] === "1") { + $contents = Storage::disk(config('odk-link.storage.xlsforms'))->get($filePath); + $contents = Str::of($contents)->replace('"', ''); + + Storage::disk(config('odk-link.storage.xlsforms'))->put($filePath, $contents); + } + + return $filePath; + } + + public function test() + { + + $data = Http::withToken($this->authenticate()) + ->get("{$this->endpoint}/projects/24/app-users") + ->throw() + ->json(); + + AppUser::create( + [ + 'id' => $data[0]['id'], + 'odk_project_id' => $data[0]['projectId'], + 'type' => $data[0]['type'], + 'display_name' => $data[0]['displayName'], + 'token' => $data[0]['token'], + ] + ); + + return 'hi'; + } + + public function unArchiveForm(Xlsform $xlsform) + { + $token = $this->authenticate(); + + return Http::withToken($token) + ->patch("{$this->endpoint}/projects/{$xlsform->owner->odkProject->id}/forms/{$xlsform->odk_id}", [ + 'state' => 'open', + ]) + ->throw() + ->json(); + } + + /** + * @param Xlsform $xlsform + * @param mixed $version + * @return Model + */ + public function createNewVersion(Xlsform $xlsform, array $versionDetails): Model + { + $token = $this->authenticate(); + + + // base xlsfile name + $fileName = collect(explode("/", $xlsform->xlsfile))->last(); + $versionSlug = Str::slug($versionDetails['version']); + + // copy xlsform file to store linked to this version forever + Storage::disk(config('odk-link.storage.xlsforms')) + ->copy( + $xlsform->xlsfile, + "xlsforms/{$xlsform->id}/versions/{$versionSlug}/{$fileName}" + ); + + // get schema from ODK Central; + $schema = Http::withToken($token) + ->get("{$this->endpoint}/projects/{$xlsform->owner->odkProject->id}/forms/{$xlsform->odk_id}/versions/{$versionDetails['version']}/fields?odata=true") + ->throw() + ->json(); + + // create new active version with latest version number; + $xlsformVersion = $xlsform->xlsformVersions()->create([ + 'version' => $versionDetails['version'], + 'xlsfile' => "xlsforms/{$xlsform->id}/versions/{$versionSlug}/{$fileName}", + 'odk_version' => $versionDetails['version'], + 'active' => true, + 'schema' => $schema, + ]); + + + return $xlsformVersion; + } + + + public function getSubmissions(Xlsform $xlsform) + { + $token = $this->authenticate(); + $oDataServiceUrl = "{$this->endpoint}/projects/{$xlsform->owner->odkProject->id}/forms/{$xlsform->odk_id}.svc"; + + + $results = Http::withToken($token) + ->get($oDataServiceUrl . '/Submissions?$expand=*') + ->throw() + ->json(); + + + // only process new submissions + $resultsToAdd = Collect($results['value'])->whereNotIn('__id', $xlsform->submissions->pluck('odk_id')->toArray()); + + + foreach ($resultsToAdd as $entry) { + + $xlsformVersion = $xlsform->xlsformVersions()->firstWhere('version', $entry['__system']['formVersion']); + + // GET schema information for the specific version + // TODO: hook this into the select variables work from the other branch... + $schema = collect($xlsformVersion->schema); + + + $entryToStore = $this->processEntry($entry, $schema); + + $submission = $xlsformVersion?->submissions()->create([ + 'odk_id' => $entry['__id'], + 'submitted_at' => (new Carbon($entry['__system']['submissionDate']))->toDateTimeString(), + 'submitted_by' => $entry['__system']['submitterName'], + 'content' => $entryToStore, + ]); + + // if app developer has defined a method of processing submission content, call that method: + $class = config('odk-link.submission.process_method.class'); + $method = config('odk-link.submission.process_method.method'); + + + //check if media is expected + if ($entry['__system']['attachmentsPresent'] > 0) { + $mediaPresent = Http::withToken($token) + ->get("{$this->endpoint}/projects/{$xlsform->owner->odkProject->id}/forms/{$xlsform->odk_id}/submissions/${entry['__id']}/attachments") + ->throw() + ->json(); + + + + foreach ($mediaPresent as $mediaItem) { + + // download the attachment + $result = Http::withToken($token) + ->get("{$this->endpoint}/projects/{$xlsform->owner->odkProject->id}/forms/{$xlsform->odk_id}/submissions/${entry['__id']}/attachments/${mediaItem['name']}") + ->throw(); + + // store the attachment locally + Storage::disk(config('odk-link.storage.media')) + ->put($mediaItem['name'], $result->body()); + + // link it to the submission via Media Library + $submission->addMediaFromDisk($mediaItem['name'], config('odk-link.storage.media')) + ->toMediaLibrary(); + + } + } + + if ($class && $method) { + $class::$method($submission); + } + + + } + + } + + // WIP + public function processEntry($entry, $schema) + { + foreach ($entry as $key => $value) { + // search for structure groups to flatten + $schemaEntry = $schema->firstWhere('name', '=', $key); + + if (!$schemaEntry) { + continue; + } + if ($schemaEntry['type'] === 'structure') { + $entry = array_merge($this->processEntry($value, $schema), $entry); + unset($entry[$key]); + } + + if ($schemaEntry['type'] === 'repeat') { + $entry[$key] = collect($entry[$key])->map(function ($repeatEntry) use ($schema) { + return $this->processEntry($repeatEntry, $schema); + })->toArray(); + } + } + + return $entry; + } +// +// public function processEntryNOPE(array $entryToStore, array $entry, Collection $schema, array $repeatPath = []): array +// { +// // get reference to correct nested part of the $entryToStore (e.g. if we are inside a repeat, we will want to add keys/values to the current level in the repeat; +// +// +// if (count($repeatPath) > 0) { +// $ref = &$entryToStore; +// foreach ($repeatPath as $path) { +// +// // check if there is already a path to here +// if (!isset($ref[$path])) { +// $ref[$path] = []; +// } +// $ref = &$ref[$path]; +// } +// dump($ref, $repeatPath); +// } +// +// +// foreach ($entry as $key => $value) { +// $schemaEntry = $schema->firstWhere('name', '=', $key); +// +// if (!$schemaEntry) { +// $ref[$key] = $value; +// continue; +// } +// +// switch ($schemaEntry['type']) { +// case 'repeat': +// +// $repeatPath[] = $key; +// $loop = 0; +// +// foreach ($value as $repeatItem) { +// array_pop($repeatPath); +// $repeatPath[] = $loop; +// $ref = $this->processEntry($entryToStore, $repeatItem, $schema, $repeatPath); +// +// $loop++; +// } +// break; +// +// case 'structure': +// $ref = $this->processEntry($entryToStore, $value, $schema, $repeatPath); +// break; +// +// default: +// $ref[$key] = $value; +// +// break; +// } +// +// } +// return $entryToStore; +// } + +} diff --git a/src/Services/SubmissionGenerator.php b/src/Services/SubmissionGenerator.php new file mode 100644 index 0000000..3bb1176 --- /dev/null +++ b/src/Services/SubmissionGenerator.php @@ -0,0 +1,375 @@ +faker = $this->withFaker(); + $this->xPathHandler = new XPathExpressionHandler(variables: $this->variables, choices: $this->choices, xlsformVersion: $this->xlsformVersion); + + // prepare choice names for the Choices picker + $this->choiceNames = $this->choices->map(fn($choice) => $choice->pluck('name')); + } + + /** + * Get a new Faker instance. + * + * @return Generator + * @throws BindingResolutionException + */ + protected function withFaker(): Generator + { + return Container::getInstance()->make(Generator::class); + } + + /** + * Process a list of variables in the order defined by their index (position in the XLS Survey) + * For the root, this is the complete list of variables in the form. (This is the default option) + * For repeat groups, this is the complete list of variables *within the repeat*. + * @throws BindingResolutionException + */ + public function processVariablesSequentially(?Collection $variablesToProcess = null): Collection + { + // if no variables have been passed, assume we are processing the entire form: + if(!$variablesToProcess) { + $variablesToProcess = $this->variables; + } + dump('variablesToProcess', $variablesToProcess); + while ($this->index < $variablesToProcess->pluck('index')->max()) { + + // handle case where the variable doesn't exist (equates to a blank line in the Excel file) + if(!isset($variablesToProcess[$this->index]) || !$variablesToProcess[$this->index]['type']) { + $this->index++; + continue; + } + $this->processVariable($variablesToProcess[$this->index]); + $this->index ++; + } + + return $this->content; + } + + + /** + * Process a single variable + * @throws BindingResolutionException + */ + + protected function processVariable($variable): void + { + // TEMPORARY CODE + // Hardcode forms selected + + //dump('variable', $variable); + + if($variable['name'] === "surveyforms") { + $this->content[$this->root . $variable['name']] = '10_1 9 13'; + return; + } + + + // If the variable is a begin group or begin repeat, append the group name to the root instead of creating a value; + if( preg_match('/begin group|begin_group/', $variable['type']) === 1) { + $this->root .= $variable['name'] . '/'; + return; + } + + // if the variable is an end group, remove the group name from the root istead of creating a value; + if( preg_match('/end group|end_group/', $variable['type']) === 1) { + $this->root = Str::of($this->root)->replaceLast($variable['name'] . '/', ''); + return; + } + + // if the variable is a begin repeat: + // - figure out how many repeats are needed; + // - create a new SubmissionGenerator for each repeat iteration and create the new content + + if( preg_match('/begin repeat|begin_repeat/', $variable['type']) === 1) { + + // dump('########################## TESTING', $variable, $this->variables); + + + // check repeat count - if none is set, randomly choose 0 - 5 repeats + if($repeatCount = $variable['repeat_count']) { + + $repeatCount = $this->xPathHandler->evaluateXPathExpression($repeatCount, $this->content, $this->repeatPos, $this->referenceContent); + + } else { + $repeatCount = $this->faker->numberBetween(0, 5); + } + + + // find the end of the repeat + + + $endRepeat = $this->variables->filter(function($varToCheck) use ($variable) { + return $variable['name'] === $varToCheck['name'] + && preg_match('/end repeat|end_repeat/', $varToCheck['type']) === 1; + })->first(); + + if(!$endRepeat) { + throw new \ParseError('It looks like the repeat group ' . $variable['name'] . ' does not have a corresponding end repeat. Please check the XLS form definition.'); + } + + $endRepeatIndex = $endRepeat['index']; + + + // grab all variables in between the current index (the start repeat) and the endRepeatIndex (the end repeat) + $repeatVariables = $this->variables + ->skipUntil(fn($var) => $var['index'] === $this->index + 1) + ->takeUntil(fn($var) => $var['index'] === $endRepeatIndex + 1); + + + // build up collection of repeat entries, ready to add to the core content; + $repeatEntries = collect([]); +dump($repeatCount); + // for each needed iteration, create a new SubmissionGenerator to handle the group. + for($j = 0; $j < $repeatCount; $j++) { + + $repeatEntries->push( + (new SubmissionGenerator( + xlsformVersion: $this->xlsformVersion, + variables: $this->variables, + choices: $this->choices, + content: collect([]), + startIndex: $this->index, + index: $this->index, + root: $this->root, + repeatPos: $j + 1, + // merge content (current layer) with referencecontent (previous layer) + // This allows for ${var} replacemenet within nested repeat groups + referenceContent: $this->content->merge($this->referenceContent)) + )->processVariablesSequentially($repeatVariables) + ); + + } + + $this->content[$this->root . $variable['name']] = $repeatEntries; + + // now the repeat group is complete, remove the group name from the root: + $this->root = Str::of($this->root)->replaceLast($variable['name'] . '/', ''); + + // reset the index to the end of the repeat group; + $this->index = $endRepeatIndex; + + return; + } + + // otherwise, generate a value for the variable and add it into the content object. + $this->content[$this->root . $variable['name']] = $this->generateValue($variable); + + } + + /** + * Function to get the full list of choices available to a select_one or select_multiple question + * The function first checks if a csv lookup is involved by checking for search() in the appearance property; + * - If a csv lookup is used, it uses the form metadata to find the correct MySQL table or view, runs the query and then merges with any hard-coded variables from the choices sheet. + * - Otherwise, it gets the correct list of choice names from the choices sheet. + * @param array $variable + * @return Collection $xlsChoiceList + */ + private function getChoicesList(array $variable): Collection + { + $xlsChoiceListName = []; + preg_match('/(?:select_one|select_multiple) (.+)/', $variable['type'], $xlsChoiceListName); + + $xlsChoiceListName = $xlsChoiceListName[1]; + + $xlsChoiceList = $this->choiceNames[$xlsChoiceListName]; + + // check if select is from choice list or db table / csv lookup: + $matches = []; + + // if searching csv file with no filters + if (preg_match('/search\(\'(.+)\'\)/', $variable['appearance'], $matches)) { + + // find the matching database table + $lookups = collect($this->xlsformVersion->xlsform->xlsformTemplate->csv_lookups); + + // this will fail if there is no lookup; + $lookup = $lookups->where('csv_name', $matches[1])->first(); + + $choiceQuery = DB::table($lookup['mysql_name']); + + if ((integer)$lookup['per_team'] === 1) { + $choiceQuery = $choiceQuery->where('owner_id', $this->xlsformVersion->xlsform->owner_id) + ->where('owner_type', $this->xlsformVersion->xlsform->owner_type); + } + $choiceList = $choiceQuery->get(); + + // there must be only 1 non-integer item in the $xlsChoiceList + foreach ($xlsChoiceList as $choiceName) { + + // ignore integer choices - they are additional hard-coded options; + if (is_int($choiceName)) { + continue; + } + + // prepend the options from the database table + $choiceList = $choiceList->pluck($choiceName); + $xlsChoiceList = $choiceList + ->merge($xlsChoiceList) + ->unique() + ->filter(fn($item) => $item !== $choiceName); + break; + } + } + + // if searching csv file with a 'matches' filter; + $matches = []; + if (preg_match('/search\([\'\"](.+)[\'\"], [\'\"]matches[\'\"], [\'\"](.+)[\'\"], \${(.+)}\)/', $variable['appearance'], $matches)) { + + // find the matching database table + $lookups = collect($this->xlsformVersion->xlsform->xlsformTemplate->csv_lookups); + $lookup = $lookups->where('csv_name', $matches[1])->first(); + + $choiceQuery = DB::table($lookup['mysql_name']); + + if ((integer)$lookup['per_team'] === 1) { + $choiceQuery = $choiceQuery->where('owner_id', $this->xlsformVersion->xlsform->owner_id) + ->where('owner_type', $this->xlsformVersion->xlsform->owner_type); + } + + // find correct submission property, (as it might have a prepended group name) + $previousProp = $this->content->keys()->filter(fn($key) => Str::of($key)->endsWith($matches[3]))->first(); + + // add matches filter + $choiceQuery = $choiceQuery->where($matches[2], '=', $this->content[$previousProp]); + $choiceList = $choiceQuery->get(); + + // there must be only 1 non-integer item in the $xlsChoiceList + foreach ($xlsChoiceList as $choiceName) { + + // ignore integer choices - they are additional hard-coded options; + if (is_int($choiceName)) { + continue; + } + + // prepend the options from the database table + $choiceList = $choiceList->pluck($choiceName); + $xlsChoiceList = $choiceList + ->merge($xlsChoiceList) + ->unique() + ->filter(fn($item) => $item !== $choiceName); + break; + } + + } + return $xlsChoiceList; + } + + /** + * @param array $variable - the variable properties from the survey sheet + * @param integer? $position - if the process is currently inside a repeat group, what is the pos(..)? (What number repeat is it?) + * @param Collection? $repeatSubmission - if the process is currently inside a repeat group, this is the data generated already for this current repeat. + * @return mixed|string|void|null + */ + private function generateValue(array $variable) + { + + switch ($variable['type']) { + case "start": + case "end": + return Carbon::now()->toISOString(); + case "today": + return Carbon::now()->format('Y-m-d'); + + case null: + case "note": + return null; + + case "geopoint": + // return space-separated string: lat long altitude accuracy + return $this->faker->latitude . " " . + $this->faker->longitude . " " . + $this->faker->numberBetween(20, 2000) . " " . + $this->faker->numberBetween(5, 200); + case (bool)preg_match('/select_one /', $variable['type']): + + $xlsChoiceList = $this->getChoicesList($variable); + + // return a random entry from the list; + return $this->faker->randomElement($xlsChoiceList); + + case (bool)preg_match('/select_multiple /', $variable['type']): + + $xlsChoiceList = $this->getChoicesList($variable); + + $variables = $this->faker->randomElements( + $xlsChoiceList, + $this->faker->numberBetween(0, count($xlsChoiceList)), + false + ); + + return collect($variables)->join(' '); + + case "deviceid": + return "faker:" . $this->faker->randomNumber(9); + + case "date": + return $this->faker->date(); + + case "integer": + return $this->faker->numberBetween(0, 50); + case "decimal": + return $this->faker->randomFloat(2,0,50); + + case "calculate": + // dump('found calculate ' . $variable['calculation'] . '- evaluating'); + return $this->xPathHandler->evaluateXPathExpression($variable['calculation'], $this->content, $this->repeatPos, $this->referenceContent); + + case "text": + return $this->faker->sentence(); + default: + return ""; + } + + } + +} diff --git a/src/Services/XPathExpressionHandler.php b/src/Services/XPathExpressionHandler.php new file mode 100644 index 0000000..9466e84 --- /dev/null +++ b/src/Services/XPathExpressionHandler.php @@ -0,0 +1,388 @@ +faker = $this->withFaker(); + } + + + /** + * Get a new Faker instance. + * + * @return Generator + * @throws BindingResolutionException + */ + protected function withFaker(): Generator + { + return Container::getInstance()->make(Generator::class); + } + + public function evaluateXPathExpression($expression, $content, $position = null, $referenceContent = null) + { + + dump('start expression', $expression); + // given we can't just "calculate the XPath expression", we need to convert the expression to something that PHP can evaluate. + // Eventually, there should probably be an accompanying dictionary of things to handle... + $expression = Str::of($expression); + + + // leave the 2nd prop of a jr:choice-name() expression, as it's needed to find the correct choice list... + $expression = $expression->replaceMatches('/jr:choice-name\((.+)\,\s*[\'\"]?\$\{(.+)\}[\'\"]?\)/', function ($matches) { + // match 1 = the name of the choice to find the label for + // match 2 = the question to use to find the choice for... + // find the correct choice list... + $varToFind = Str::of($matches[2])->replaceMatches('/[${}\']/', '')->trim(); + + $varFound = $this->variables->filter(fn($var) => $var['name'] === (string)$varToFind)->first(); + + // get the choice list... + $choiceList = Str::of($varFound['type'])->replaceMatches('/(?:select_one|select_multiple)/', '')->trim(); + + // return the original jr:choice-name for processing after some other replacements; + // replace the 2nd property with the choice list to ease the later processing; + return "jr:choice-name(" . $matches[1] . ", " . $choiceList . ")"; + }); + + // ************** handle ${varname} references: ***************** // + $matches = []; + preg_match_all('/\$\{([A-z_-]+)\}/', $expression, $matches); + + + $varsToReplace = $matches[1]; + foreach ($varsToReplace as $var) { + if($var == "species") { + dump('SUBCHECK: ', $content, $referenceContent); + } + // first, check if the $var is in the full variables list. If not, there is a form syntax error: + if($this->variables->pluck('name')->doesntContain($var)) { + throw new ParseError('No variable with name ${' . $var . '} was found in the form.'); + } + + // find $var with any potential group prefix: + $previousProp = $content->keys()->filter(fn($key) => Str::of($key)->endsWith($var))->first(); + dump($previousProp); + if ($previousProp) { + $replacement = $content[$previousProp]; + dump($replacement); + if(!is_numeric($replacement)) { + $replacement = '"' . $replacement . '"'; + } + + $expression = $expression->replace('${' . $var . '}', $replacement); + + if($previousProp == "trt_main/species") { + dump('hello there', $expression); + } + continue; + } + + + // if the previousProp is not found in the main $content, check the $reference content... + $previousProp = $referenceContent?->keys()->filter(fn($key) => Str::of($key)->endsWith($var))->first(); + if ($previousProp) { + $replacement = $referenceContent[$previousProp]; + if(!is_numeric($replacement)) { + $replacement = '"' . $replacement . '"'; + } + $expression = $expression->replace('${' . $var . '}', $replacement); + continue; + } + + // if we haven't found the variable so far, it is likely nested inside an already-complete repeat group. + // 1. Find any complete repeat groups (variable is collection) + // 2. Check inside collection for the property name; + // 3. If found, process... + + $innerRepeatValue = $this->checkContentForRepeats($content, $var); + + // if no inner repeat value is found in the main content, check the reference content; + if(!$innerRepeatValue && $referenceContent) { + $innerRepeatValue = $this->checkContentForRepeats($referenceContent, $var); + } + + if($innerRepeatValue) { + // prepare the array of content + // dump('inner repeat value found'); + // dump('pre expression', $expression, $var); + + $replacement = "["; + foreach($innerRepeatValue as $item) { + $replacement .= "'" . $item . "','"; + } + $replacement .= "]"; + $expression = $expression->replace('${' . $var . '}', $replacement); + + // dump('post expression', $expression); + + continue; + } + + // if we get this far, there is no previous prop found. This means that the ${var} being referenced either: + // 1. does not exist due to relevancy + // 2. is inside a repeat group that had 0 iterations this time round. + // 3. is later on in the form and has not yet been calculated. + // For options 1 and 2, this is fine and we can return an empty string. For option 3, there's not much we can do until we refactor... + + $expression = $expression->replace('${' . $var . '}', ''); + + } + + // ************** handle position(..) references: ***************** // + $expression = $expression->replace('position(..)', $position ?? '1'); + + // ************** count-selected() to count() ***************** // + $expression = $expression->replace('count-selected()', 'count([])'); + $expression = $expression->replace('count-selected("")', 'count([])'); + + $expression = $expression->replaceMatches('/count-selected\([\'\"]([A-z0-9\_\-\s]+)[\'\"]\)/', function ($matches) { + $output = "count(["; + foreach (explode(" ", $matches[1]) as $item) { + $output .= "'" . $item . "',"; + } + + $output .= "])"; + + return $output; + }); + + // handle case where counted variable does not exist (due to relevancy or no repeats) + $expression = $expression->replace('count()', ''); + + // ************** handle selected-at() function ***************** // + $expression = $expression->replaceMatches('/selected-at\([\'\"]*([A-z0-9\_\-\s]+)[\'\"]*\,([A-z0-9\_\-\s]+)\)/', function ($matches) { + // match 1 = variable; + // match 2 = the position to get from; + + $items = explode(" ", $matches[1]); + $index = eval('return ' . $matches[2] . ';'); + return '"' . $items[$index] . '"'; + + }); + + // ************** handle jr:choice-name() function ***************** // + $expression = $expression->replaceMatches('/jr:choice-name\([\"\']?([A-z0-9-]+)[\"\']?\,\s*[\"\']?([A-z0-9-\s]+)[\"\']?\)/', function ($matches) { + // match 1 = the name of the choice to find the label for + // match 2 = name of the choice list; + dump($matches, $this->choices); + $choices = $this->choices[(string)$matches[2]]->pluck('label', 'name'); + + return '"' . $choices[$matches[1]] . '"'; + }); + + + // ************** handle if() function ***************** // + // NOTE - does not handle situations where 1st or 2nd property of if() statement contains a comma, so pre-filter for that:[ + + $expression = $expression->replaceMatches('/if\((.+)\,(.+)\,(.+)\)/', function ($matches) { + // match 1 = boolean query + // match 2 = result if statement === true + // match 3 = result if statement === false + + try { + + // turn "=" into a php-friendly "==" + $statement = Str::of($matches[1])->replace('=', "=="); + + // dump('if-test', $statement); + $test = eval('return ' . $statement . ';'); + + if ($test) { + return '"' . $matches[2] . '"'; + } + + return '"' . $matches[3] . '"'; + + } catch (ParseError $exception) { + // this cannot handle situations where the query part of the if() statement includes a comma. This is the likely cause of this ParseError exception + + // for now, return random string; + return $this->faker->words(1); + + } catch (\Throwable $exception) { + return $this->faker->words(1); + } + + }); + + // ************** handle pulldata() function ***************** // + // search for strings matching: pulldata('something', 'something', 'something', something) + $test = false; + if($expression->contains('treatment_list')) { + $test = true; + } + $expression = $expression->replaceMatches('/pulldata\([\'\"]+(.+)[\'\"]+\,\s*[\'\"]+(.+)[\'\"]+\,\s*[\'\"]+(.+)[\'\"]+\,\s*(.+)\)/', function ($matches) { + // match 1 = csv file name ( + // match 2 = column header to use for the return value + // match 3 = column header to use for search + // match 4 = the value to search + $csvName = (string)$matches[1]; + $returnHeader = (string)$matches[2]; + $searchHeader = (string)$matches[3]; + $searchValue = (string)$matches[4]; // might be surrounded by quotes. Might not... + + // if the $searchValue is a string, unwrap it from quotes + $searchValue = Str::of($searchValue); + if($searchValue->startsWith(["'", '"']) || $searchValue->endsWith(["'", '"'])) { + $searchValue = $searchValue->trim("'")->trim('"'); + } + + + // find the db table that matches the csv file + $lookups = collect($this->xlsform->xlsform->csv_lookups); + $lookup = $lookups->where('csv_name', $csvName)->first(); + + $choiceQuery = DB::table($lookup['mysql_name']); + + if ((integer)$lookup['per_team'] === 1) { + $choiceQuery = $choiceQuery->where('team_id', $this->xlsform->team->id); + } + $choiceList = $choiceQuery->get(); + + // find the item in the choice list where the search column === search value + return '"' . $choiceList->pluck($returnHeader, $searchHeader)[(string) $searchValue] . '"'; + + }); + + // ************** handle coalesce() function ***************** // + // search for strings matching: coalesce('something', 'something') + // NOTE - this does not work when one or both parameters of the coalesce function include commas... + + $expression = $expression->replaceMatches('/coalesce\((.+)\,(.+)\)/', function ($matches) { + return $matches[1] . ' ?? ' . $matches[2]; + }); + + // ************** handle translate() function ***************** // + // search for strings matching: translate('string to find', 'needle', 'replace') + // NOTE - this does not work when one or both parameters of the coalesce function include commas... + + $expression = $expression->replaceMatches('/translate\((.+)\,\s*[\'\"](.+)[\'\"],\s*[\'\"](.+)[\'\"]\)/', function ($matches) { + // $matches[1] -> the string to search + // $matches[2] -> the substring to find + // $matches[3] -> the replkacment + return (string) Str::of($matches[1])->replace($matches[2], $matches[3]); + }); + + // when replacing expression components with strings, we may end up with multiple quote marks stacked together. Remove them before evaluating: + // iterate over and over until all double quotes have been swapped with single quotes. + // but then we might have " something " something else " ... + do { + + // if double quotes are at the start, replace with 1: + $expression = $expression->replaceMatches('/^[\"\'][\s]*[\"\']/', function ($matches) { + return '"'; + }); + + // if double quotes are at the end, replace with 1: + $expression = $expression->replaceMatches('/[\"\'][\s]*[\"\']$/', function ($matches) { + return '"'; + }); + + // if double quotes are in the middle, concatenate the strings... but only if there are + $expression = $expression->replaceMatches(('/(?=[\"\'].+)[\"\'][\s]*[\"\'](?=.+[\"\'])/'), function ($matches) { + return '"'; + }); + + $quotesFound = preg_match('/\"[\s]*\"/', (string)$expression); + + } while ($quotesFound === 1); + + + // HANDLE RESULT Calculation + dump('result calc'); + dump((string)$expression); + + try { + $result = eval('return ' . $expression . ';'); + } catch (ParseError $exception) { + // if the resulting calculate cannot be parsed, simply return a random string. + // TODO: enable user to specify if un-evaluatable calculate should return a random value based on the given parameters or halt execution and return the error. + + $result = $this->faker->words(1, true); + } + + return $result; + + } + + + public function checkContentForRepeats(Collection $content, string $varName): ?array + { + // start off with null; + $previousProp = null; + + dump('checking content for repeats'); + + foreach($content as $index => $value) { + if($value instanceof Collection) { + // check inside the collection... + + // dump('found repeat: ' . $index); + $previousProp = $this->checkRepeatForVariableName($value, $varName); + + // if the prop has been found inside a repeat collection, stop checking other repeat collections; + if($previousProp) { + break; + } + } + } + dump('returning value from checkContentForRepeats', $previousProp); + return $previousProp; + } + + /** + * Check inside a completed repeat group collection to search for a variable name referenced in the form using ${varName}. + * @param Collection $repeatCollection + * @param string $varName + */ + public function checkRepeatForVariableName(Collection $repeatCollection, string $varName): ?array + { + // as we are checking repeats, the result will be an array, that can then be processed into a string based on the surrounding expression + // start off with an empty array; + $previousProp = null; + $returnValue = []; + + dump('looking for variable ' . $varName); + dump('checking repeat for variable name', $repeatCollection); + // when outside of a repeat, ${innerRepeatVarName} will return the entire nodeset of values. + // This means, if there are 5 repeats and ${varName} has a non-null value in 4 of them, the expression ${varName} should be replaced by all the values. + foreach($repeatCollection as $repeatInstance) { + $previousProp = $repeatInstance->keys()->filter(fn($key) => Str::of($key)->endsWith($varName))->first(); + + if($previousProp) { + dump('prop found', $repeatInstance[$previousProp]); + $returnValue[] = $repeatInstance[$previousProp]; + } + } + + if($previousProp) { + // dump('returning value from checkRepeatForVariableName...', $returnValue); + return $returnValue; + } + return null; + + } +} diff --git a/src/Skeleton.php b/src/Skeleton.php deleted file mode 100644 index 66fab60..0000000 --- a/src/Skeleton.php +++ /dev/null @@ -1,7 +0,0 @@ -font('DM Sans') - ->primaryColor(Color::Amber) - ->secondaryColor(Color::Gray) - ->warningColor(Color::Amber) - ->dangerColor(Color::Rose) - ->successColor(Color::Green) - ->grayColor(Color::Gray) - ->theme('skeleton'); - } - - public function boot(Panel $panel): void - { - // - } -} diff --git a/src/Testing/TestsSkeleton.php b/src/Testing/TestsFilamentOdkLink.php similarity index 56% rename from src/Testing/TestsSkeleton.php rename to src/Testing/TestsFilamentOdkLink.php index 0e33b51..bfc7a72 100644 --- a/src/Testing/TestsSkeleton.php +++ b/src/Testing/TestsFilamentOdkLink.php @@ -1,13 +1,13 @@ limit(1000)->wrapper([ + 'href' => function($crud, $column, $entry) { + return config('odk-link.odk.url')."/#/projects/".$entry->odkProject->id; + } + ]); + CRUD::column('xlsforms')->type('relationship_count')->suffix(''); + + } + + public function setupXlsformCreateFields(): void + { + CRUD::field('xlsforms') + ->type('relationship') + ->subfields([ + [ + 'name' => 'xlsformTemplate', + 'type' => 'relationship', + ], + [ + 'name' => 'title', + 'label' => 'If you want this form to have a custom title, add it here.', + 'hint' => 'Leave empty to inherit the default title from the chosen template', + 'type' => 'text', + //'type' => 'xlsform-title', + //'view_namespace' => 'stats4sd.odk-link::fields', + ], + ]); + } + + /** + * Overwrite the default "show" method for CRUD panels to show a custom page that includes all the XLSform details of the selected owner. + **/ + public function show($id) + { + $this->crud->hasAccessOrFail('show'); + + // get entry ID from Request (makes sure its the last ID for nested resources) + $id = $this->crud->getCurrentEntryId() ?? $id; + + // get the info for that entry (include softDeleted items if the trait is used) + if ($this->crud->get('show.softDeletes') && in_array('Illuminate\Database\Eloquent\SoftDeletes', class_uses($this->crud->model))) { + $this->data['entry'] = $this->crud->getModel()->withTrashed()->findOrFail($id); + } else { + $this->data['entry'] = $this->crud->getEntryWithLocale($id); + } + + $this->data['crud'] = $this->crud; + $this->data['title'] = $this->crud->getTitle() ?? trans('backpack::crud.preview').' '.$this->crud->entity_name; + + // load the view from /resources/views/vendor/backpack/crud/ if it exists, otherwise load the one in the package + return view($this->crud->getShowView(), $this->data); + } + +} diff --git a/src/Traits/HasXlsforms.php b/src/Traits/HasXlsforms.php new file mode 100644 index 0000000..b64b531 --- /dev/null +++ b/src/Traits/HasXlsforms.php @@ -0,0 +1,80 @@ +make(OdkLinkService::class); + + // when the model is created; automatically create an associated project on ODK Central; + static::created(function ($owner) use ($odkLinkService) { + $owner->createLinkedOdkProject($odkLinkService, $owner); + }); + } + + // Used as the human-readable label for the owners of forms. Uses the same variable name that some Laravel Backpack fields expect (e.g. Relationship) + // Xls Form titles are in the format `$owner->$nameAttribute . '-' . $xlsform->title` + public string $identifiableAttribute = 'name'; + + public function xlsforms(): MorphMany + { + return $this->morphMany(Xlsform::class, 'owner'); + } + + // Private templates are owned by a single form owner. + // All owners have access to all public templates (templates where available = 1) + public function xlsformTemplates(): MorphMany + { + return $this->morphMany(XlsformTemplate::class, 'owner'); + } + + public function odkProject(): MorphOne + { + return $this->morphOne(OdkProject::class, 'owner'); + } + + /** + * @param mixed $odkLinkService + * @param $owner + * @return void + */ + function createLinkedOdkProject(OdkLinkService $odkLinkService): void + { + $odkProjectInfo = $odkLinkService->createProject($this->name); + $odkProject = $this->odkProject()->create([ + 'id' => $odkProjectInfo['id'], + 'name' => $odkProjectInfo['name'], + 'archived' => $odkProjectInfo['archived'], + ]); + + // create an app user + assign to all forms in the project by giving them the admin role; + $odkAppUserInfo = $odkLinkService->createProjectAppUser($odkProject); + + $odkProject->appUsers()->create([ + 'id' => $odkAppUserInfo['id'], + 'display_name' => $odkAppUserInfo['displayName'], + 'type' => 'field_key', // legacy term for "App User" in ODK Central; + 'token' => $odkAppUserInfo['token'], // the token required to generate the ODK QR Code; + 'can_access_all_forms' => true, + ]); + } + + +} diff --git a/src/View/Components/Qr.php b/src/View/Components/Qr.php new file mode 100644 index 0000000..86e4210 --- /dev/null +++ b/src/View/Components/Qr.php @@ -0,0 +1,26 @@ +in(__DIR__); diff --git a/tests/TestCase.php b/tests/TestCase.php index 8ef205d..d91b762 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -1,6 +1,6 @@ 'VendorName\\Skeleton\\Database\\Factories\\' . class_basename($modelName) . 'Factory' + fn (string $modelName) => 'Stats4sd\\FilamentOdkLink\\Database\\Factories\\' . class_basename($modelName) . 'Factory' ); } @@ -48,7 +48,7 @@ protected function getPackageProviders($app) SupportServiceProvider::class, TablesServiceProvider::class, WidgetsServiceProvider::class, - SkeletonServiceProvider::class, + FilamentOdkLinkServiceProvider::class, ]; } @@ -57,7 +57,7 @@ public function getEnvironmentSetUp($app) config()->set('database.default', 'testing'); /* - $migration = include __DIR__.'/../database/migrations/create_skeleton_table.php.stub'; + $migration = include __DIR__.'/../database/migrations/create_filament-odk-link_table.php.stub'; $migration->up(); */ }