Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
179802c
patch for AllowEdit for Forms5
tpokorra Dec 31, 2024
13b633e
drop DeleteSubmission, and make Clear button work for AllowEdit
tpokorra Dec 31, 2024
e72d912
fix php-cs lint issues
tpokorra Dec 31, 2024
262844f
small fixes
tpokorra Dec 31, 2024
02e544f
run prettier
tpokorra Dec 31, 2024
558fb29
more fixes
tpokorra Dec 31, 2024
5168c83
fix existing tests by adding AllowEdit
tpokorra Jan 3, 2025
b6a6e18
fix lint issue
tpokorra Jan 3, 2025
84cee8c
fix existing tests by adding AllowEdit
tpokorra Jan 3, 2025
0a82bbc
fix testCanSubmit with AllowEdit=false
tpokorra Jan 3, 2025
15194ac
extend testCanSubmit by allowEditGood and allowEditNotGood
tpokorra Jan 3, 2025
e67f7d7
add unit test testUpdateSubmission_answers
tpokorra Jan 4, 2025
117e71e
add unit test testGetFormWithAnswers for AllowEdit
tpokorra Jan 4, 2025
20393a3
add unit test testGetFormAllowEditWithoutAnswers
tpokorra Jan 4, 2025
b3f46d7
improve unit tests testGetFormAllowEditWithoutAnswers
tpokorra Jan 4, 2025
a4495bb
use existing function loadFormForSubmission instead of new function c…
tpokorra Jan 4, 2025
abfc068
new function canDeleteSubmission with Tests. improve tests for canDel…
tpokorra Jan 4, 2025
6a5d365
drop function canDeleteSubmission. it is unrelated to this PR
tpokorra Jan 4, 2025
f8c54c1
adjust label for AllowEdit
tpokorra Jan 4, 2025
e597620
rename migration to Version 5 and current date
tpokorra Jan 9, 2025
87e3b25
drop IconDeleteSvg from Submit.vue
tpokorra Jan 9, 2025
bc6761d
fix error messages with multiple ors
tpokorra Jan 9, 2025
217736e
use PUT for updating submission
tpokorra Jan 9, 2025
68296b2
fail silently for MultipleObjectsReturnedException from findByFormAnd…
tpokorra Jan 9, 2025
88ef56a
updateSubmission: first check if editing is allowed and by this user
tpokorra Jan 9, 2025
50c64bc
add integration test testUpdateSubmission
tpokorra Jan 9, 2025
e504d77
fix missing MultipleObjectsReturnedException for FormsService
tpokorra Jan 9, 2025
155ab5d
testUpdateSubmission: POST files instead of PUT
tpokorra Jan 21, 2025
09cdbb6
fixes for Psalm: add allowEdit
tpokorra Jan 21, 2025
bf941df
fix ApiRoute for updateSubmission
tpokorra Jan 21, 2025
b821904
fix RespectAdminSettingsTest
tpokorra Jan 21, 2025
821c9e4
fix openapi errors
tpokorra Jan 21, 2025
94d2c9a
another fix for openapi
tpokorra Jan 21, 2025
73be15f
extend FormsForm for psalm
tpokorra Jan 21, 2025
f301377
update openapi.json
tpokorra Jan 21, 2025
a347549
another fix for FormsForm
tpokorra Jan 21, 2025
53e1d77
another fix for FormsForm
tpokorra Jan 21, 2025
cea5b82
another attempt to fix openapi issues
tpokorra Jan 21, 2025
712dd45
simplify check in updateSubmission
tpokorra Jan 22, 2025
053d913
testUpdateSubmission: set AllowEdit for form
tpokorra Jan 22, 2025
9a20f25
testUpdateSubmission: use different http client for user1
tpokorra Jan 22, 2025
5d1e27b
fix password for user1
tpokorra Jan 22, 2025
264b656
fix testUpdateSubmission
tpokorra Jan 22, 2025
31af1b3
fix testUpdateSubmission
tpokorra Jan 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/DataStructure.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ This document describes the Object-Structure, that is used within the Forms App
| isAnonymous | Boolean | | If Answers will be stored anonymously |
| state | Integer | [Form state](#form-state) | The state of the form |
| submitMultiple | Boolean | | If users are allowed to submit multiple times to the form |
| allowEdit | Boolean | | If users are allowed to edit or delete their response |
| showExpiration | Boolean | | If the expiration date will be shown on the form |
| canSubmit | Boolean | | If the user can Submit to the form, i.e. calculated information out of `submitMultiple` and existing submissions. |
| permissions | Array of [Permissions](#permissions) | Array of permissions regarding the form |
Expand All @@ -46,6 +47,7 @@ This document describes the Object-Structure, that is used within the Forms App
"expires": 0,
"isAnonymous": false,
"submitMultiple": true,
"allowEdit": false,
"showExpiration": false,
"canSubmit": true,
"permissions": [
Expand Down
157 changes: 149 additions & 8 deletions lib/Controller/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@
'showToAllUsers' => false,
]);
$form->setSubmitMultiple(false);
$form->setAllowEdit(false);
$form->setShowExpiration(false);
$form->setExpires(0);
$form->setIsAnonymous(false);
Expand Down Expand Up @@ -1294,7 +1295,7 @@
continue;
}

$this->storeAnswersForQuestion($form, $submission->getId(), $questions[$questionIndex], $answerArray);
$this->storeAnswersForQuestion($form, $submission->getId(), $questions[$questionIndex], $answerArray, false);
}

$this->formMapper->update($form);
Expand All @@ -1309,6 +1310,87 @@
return new DataResponse(null, Http::STATUS_CREATED);
}

/**
* Update an existing submission
*
* @param int $formId the form id
* @param int $submissionId the submission id
* @param array<string, list<string>> $answers [question_id => arrayOfString]
* @param string $shareHash public share-hash -> Necessary to submit on public link-shares.
* @return DataResponse<Http::STATUS_OK, int, array{}>
* @throws OCSBadRequestException Can only update submission if AllowEdit is set and the answers are valid
* @throws OCSForbiddenException Can only update your own submission
*
* 200: the id of the updated submission
*/
#[CORS()]
#[NoAdminRequired()]
#[NoCSRFRequired()]
#[PublicPage()]
#[ApiRoute(verb: 'PUT', url: '/api/v3/forms/{formId}/submissions/{submissionId}')]
public function updateSubmission(int $formId, int $submissionId, array $answers, string $shareHash = ''): DataResponse {
$this->logger->debug('Updating submission: formId: {formId}, answers: {answers}, shareHash: {shareHash}', [
'formId' => $formId,
'answers' => $answers,
'shareHash' => $shareHash,
]);

$form = $this->loadFormForSubmission($formId, $shareHash);

if (!$form->getAllowEdit()) {
throw new OCSBadRequestException('Can only update if AllowEdit is set');
}

$questions = $this->formsService->getQuestions($formId);
// Is the submission valid
$isSubmissionValid = $this->submissionService->validateSubmission($questions, $answers, $form->getOwnerId());

Check failure on line 1346 in lib/Controller/ApiController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable30

AssignmentToVoid

lib/Controller/ApiController.php:1346:3: AssignmentToVoid: Cannot assign $isSubmissionValid to type void (see https://psalm.dev/121)

Check failure on line 1346 in lib/Controller/ApiController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable31

AssignmentToVoid

lib/Controller/ApiController.php:1346:3: AssignmentToVoid: Cannot assign $isSubmissionValid to type void (see https://psalm.dev/121)
if (is_string($isSubmissionValid)) {

Check failure on line 1347 in lib/Controller/ApiController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable30

TypeDoesNotContainType

lib/Controller/ApiController.php:1347:7: TypeDoesNotContainType: Type null for $isSubmissionValid is never string (see https://psalm.dev/056)

Check failure on line 1347 in lib/Controller/ApiController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable31

TypeDoesNotContainType

lib/Controller/ApiController.php:1347:7: TypeDoesNotContainType: Type null for $isSubmissionValid is never string (see https://psalm.dev/056)
throw new OCSBadRequestException($isSubmissionValid);

Check failure on line 1348 in lib/Controller/ApiController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable30

NoValue

lib/Controller/ApiController.php:1348:37: NoValue: All possible types for this argument were invalidated - This may be dead code (see https://psalm.dev/179)

Check failure on line 1348 in lib/Controller/ApiController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable31

NoValue

lib/Controller/ApiController.php:1348:37: NoValue: All possible types for this argument were invalidated - This may be dead code (see https://psalm.dev/179)
}
if ($isSubmissionValid === false) {

Check failure on line 1350 in lib/Controller/ApiController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable30

TypeDoesNotContainType

lib/Controller/ApiController.php:1350:7: TypeDoesNotContainType: null does not contain false (see https://psalm.dev/056)

Check failure on line 1350 in lib/Controller/ApiController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable31

TypeDoesNotContainType

lib/Controller/ApiController.php:1350:7: TypeDoesNotContainType: null does not contain false (see https://psalm.dev/056)
throw new OCSBadRequestException('At least one submitted answer is not valid');
}

// get existing submission of this user
try {
$submission = $this->submissionMapper->findByFormAndUser($form->getId(), $this->currentUser->getUID());
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. It makes sense to move the ownership check to submission before validateSubmission
  2. It is better to rewrite this check to $this->submissionMapper->findById($submissionId). This will cover situations with multiple submissions and is a faster method.

} catch (DoesNotExistException $e) {
throw new OCSBadRequestException('Cannot update a non existing submission');
}

if ($submissionId != $submission->getId()) {
throw new OCSForbiddenException('Can only update your own submissions');
}

$submission->setTimestamp(time());
$this->submissionMapper->update($submission);

if (empty($answers)) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In what cases can this be? When deleting a submission?

// Clear Answers
foreach ($questions as $question) {
$this->storeAnswersForQuestion($form, $submission->getId(), $question, [''], true);
}
} else {
// Process Answers
foreach ($answers as $questionId => $answerArray) {
// Search corresponding Question, skip processing if not found
$questionIndex = array_search($questionId, array_column($questions, 'id'));
if ($questionIndex === false) {
continue;
}

$question = $questions[$questionIndex];

$this->storeAnswersForQuestion($form, $submission->getId(), $question, $answerArray, true);
}
}

//Create Activity
$this->formsService->notifyNewSubmission($form, $submission);

return new DataResponse($submissionId);
}

/**
* Delete a specific submission
*
Expand Down Expand Up @@ -1522,14 +1604,23 @@
// private functions

/**
* Insert answers for a question
* Insert or update answers for a question
*
* @param Form $form
* @param int $submissionId
* @param array $question
* @param string[]|array<array{uploadedFileId: string, uploadedFileName: string}> $answerArray
* @param bool $update
*/
private function storeAnswersForQuestion(Form $form, $submissionId, array $question, array $answerArray): void {
private function storeAnswersForQuestion(Form $form, int $submissionId, array $question, array $answerArray, bool $update): void {
// get stored answers for this question
$storedAnswers = [];
if ($update) {
$storedAnswers = $this->answerMapper->findBySubmissionAndQuestion($submissionId, $question['id']);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To generate fewer database queries, before foreach where the storeAnswersForQuestion method is called, get all the answers in one call through $this->answerMapper->findBySubmission and pass here

}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will try to propose a simpler solution for this method

$deletingStoredAnswers = [];
foreach ($storedAnswers as $storedAnswer) {
$deletingStoredAnswers[$storedAnswer->getText() ] = $storedAnswer
}


$newAnswerTexts = [];
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delete


foreach ($answerArray as $answer) {
$answerEntity = new Answer();
$answerEntity->setSubmissionId($submissionId);
Expand All @@ -1546,6 +1637,33 @@
} elseif (!empty($question['extraSettings']['allowOtherAnswer']) && strpos($answer, Constants::QUESTION_EXTRASETTINGS_OTHER_PREFIX) === 0) {
$answerText = str_replace(Constants::QUESTION_EXTRASETTINGS_OTHER_PREFIX, '', $answer);
}

if (!array_key_exists($question['id'], $newAnswerTexts)) {
$newAnswerTexts[$question['id']] = [];
}
$newAnswerTexts[$question['id']][] = $answerText;

// has this answer already been stored?
$foundAnswer = false;
foreach ($storedAnswers as $storedAnswer) {
if ($storedAnswer->getText() == $answerText) {
// nothing to be changed
$foundAnswer = true;
break;
}
}
if (!$foundAnswer) {
if ($answerText === '') {
continue;
}
// need to add answer
$answerEntity = new Answer();
$answerEntity->setSubmissionId($submissionId);
$answerEntity->setQuestionId($question['id']);
$answerEntity->setText($answerText);
$this->answerMapper->insert($answerEntity);
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delete


} elseif ($question['type'] === Constants::ANSWER_TYPE_FILE) {
$uploadedFile = $this->uploadedFileMapper->getByUploadedFileId($answer['uploadedFileId']);
$answerEntity->setFileId($uploadedFile->getFileId());
Expand All @@ -1565,20 +1683,43 @@
$file->move($folder->getPath() . '/' . $name);

$answerText = $name;

$answerEntity->setText($answerText);
$this->answerMapper->insert($answerEntity);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delete

} else {
$answerText = $answer; // Not a multiple-question, answerText is given answer
}

if ($answerText === '') {
continue;
if (!empty($storedAnswers)) {
$answerEntity = $storedAnswers[0];
$answerEntity->setText($answerText);
$this->answerMapper->update($answerEntity);
} else {
if ($answerText === '') {
continue;
}
$answerEntity = new Answer();
$answerEntity->setSubmissionId($submissionId);
$answerEntity->setQuestionId($question['id']);
$answerEntity->setText($answerText);
$this->answerMapper->insert($answerEntity);
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

revert

}

$answerEntity->setText($answerText);
$this->answerMapper->insert($answerEntity);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (array_key_exists($answerText, $deletingStoredAnswers)) {
unset($deletingStoredAnswers[$answerText])
continue; // answer already been stored
}

$answerEntity->setText($answerText);
$this->answerMapper->insert($answerEntity);

if ($uploadedFile) {
$this->uploadedFileMapper->delete($uploadedFile);
}
}

if (in_array($question['type'], Constants::ANSWER_TYPES_PREDEFINED)) {
// drop all answers that are not in new set of answers
foreach ($storedAnswers as $storedAnswer) {
$questionId = $storedAnswer->getQuestionId();

if (empty($newAnswerTexts[$questionId]) || !in_array($storedAnswer->getText(), $newAnswerTexts[$questionId])) {
$this->answerMapper->delete($storedAnswer);
}
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (!empty( $deletingStoredAnswers) {
foreach ( $deletingStoredAnswers as $storedAnswer) {
$this->answerMapper->delete($storedAnswer);
}
}

}

private function loadFormForSubmission(int $formId, string $shareHash): Form {
Expand Down
20 changes: 20 additions & 0 deletions lib/Db/AnswerMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,26 @@ public function findBySubmission(int $submissionId): array {
return $this->findEntities($qb);
}

/**
* @param int $submissionId
* @param int $questionId
* @throws \OCP\AppFramework\Db\DoesNotExistException if not found
* @return Answer[]
*/

public function findBySubmissionAndQuestion(int $submissionId, int $questionId): array {
$qb = $this->db->getQueryBuilder();

$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()->eq('submission_id', $qb->createNamedParameter($submissionId, IQueryBuilder::PARAM_INT)),
$qb->expr()->eq('question_id', $qb->createNamedParameter($questionId, IQueryBuilder::PARAM_INT))
);

return $this->findEntities($qb);
}

/**
* @param int $submissionId
*/
Expand Down
6 changes: 6 additions & 0 deletions lib/Db/Form.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
* @method void setIsAnonymous(bool $value)
* @method int getSubmitMultiple()
* @method void setSubmitMultiple(bool $value)
* @method int getAllowEdit()
* @method void setAllowEdit(bool $value)
* @method int getShowExpiration()
* @method void setShowExpiration(bool $value)
* @method int getLastUpdated()
Expand All @@ -58,6 +60,7 @@ class Form extends Entity {
protected $expires;
protected $isAnonymous;
protected $submitMultiple;
protected $allowEdit;
protected $showExpiration;
protected $submissionMessage;
protected $lastUpdated;
Expand All @@ -71,6 +74,7 @@ public function __construct() {
$this->addType('expires', 'integer');
$this->addType('isAnonymous', 'boolean');
$this->addType('submitMultiple', 'boolean');
$this->addType('allowEdit', 'boolean');
$this->addType('showExpiration', 'boolean');
$this->addType('lastUpdated', 'integer');
$this->addType('state', 'integer');
Expand Down Expand Up @@ -140,6 +144,7 @@ public function setAccess(array $access): void {
* expires: int,
* isAnonymous: bool,
* submitMultiple: bool,
* allowEdit: bool,
* showExpiration: bool,
* lastUpdated: int,
* submissionMessage: ?string,
Expand All @@ -160,6 +165,7 @@ public function read() {
'expires' => (int)$this->getExpires(),
'isAnonymous' => (bool)$this->getIsAnonymous(),
'submitMultiple' => (bool)$this->getSubmitMultiple(),
'allowEdit' => (bool)$this->getAllowEdit(),
'showExpiration' => (bool)$this->getShowExpiration(),
'lastUpdated' => (int)$this->getLastUpdated(),
'submissionMessage' => $this->getSubmissionMessage(),
Expand Down
23 changes: 23 additions & 0 deletions lib/Db/SubmissionMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,29 @@ public function findByForm(int $formId): array {
return $this->findEntities($qb);
}

/**
* @param int $formId
* @param string $userId
*
* @return Submission
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException if more than one result
* @throws \OCP\AppFramework\Db\DoesNotExistException if not found
*/
public function findByFormAndUser(int $formId, string $userId): Submission {
$qb = $this->db->getQueryBuilder();

$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()->eq('form_id', $qb->createNamedParameter($formId, IQueryBuilder::PARAM_INT)),
$qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
)
//Newest submissions first
->orderBy('timestamp', 'DESC');

return $this->findEntity($qb);
}

/**
* @param int $id
* @return Submission
Expand Down
1 change: 1 addition & 0 deletions lib/FormsMigrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ public function import(IUser $user, IImportSource $importSource, OutputInterface
$form->setExpires($formData['expires']);
$form->setIsAnonymous($formData['isAnonymous']);
$form->setSubmitMultiple($formData['submitMultiple']);
$form->setAllowEdit($formData['allowEdit']);
$form->setShowExpiration($formData['showExpiration']);

$this->formMapper->insert($form);
Expand Down
42 changes: 42 additions & 0 deletions lib/Migration/Version050000Date20250109201500.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Forms\Migration;

use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;

class Version050000Date20250109201500 extends SimpleMigrationStep {

/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$table = $schema->getTable('forms_v2_forms');

if (!$table->hasColumn('allow_edit')) {
$table->addColumn('allow_edit', Types::BOOLEAN, [
'notnull' => false,
'default' => 0,
]);

return $schema;
}

return null;
}
}
4 changes: 4 additions & 0 deletions lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@
* isAnonymous: bool,
* lastUpdated: int,
* submitMultiple: bool,
* allowEdit: bool,
* showExpiration: bool,
* canSubmit: bool,
* permissions: list<FormsPermission>,
Expand All @@ -119,6 +120,9 @@
* shares: list<FormsShare>,
* submissionCount?: int,
* submissionMessage: ?string,
* answers?: array<string,mixed>,
* newSubmission?: bool,
* submissionId?: int,
* }
*
* @psalm-type FormsUploadedFile = array{
Expand Down
Loading
Loading