Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: allow editing of submission by the user #1690

Draft
wants to merge 42 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
89a93a2
add new property allowEdit to forms
tpokorra Aug 15, 2023
21c04d9
adjust tests, add allowEdit=false
tpokorra Aug 15, 2023
112d919
update docs with allowEdit
tpokorra Aug 15, 2023
4f4a478
setting allowEdit can now be edited
tpokorra Aug 15, 2023
e5b54a7
if AllowEdit is enabled, allow editing and pass the original submissi…
tpokorra Aug 18, 2023
821ef2b
update Tests with answerMapper
tpokorra Aug 18, 2023
97f18f4
AllowEdit: Delete previous submissions by this user before submitting…
tpokorra Aug 18, 2023
7ac7298
return selected values as a separate array
tpokorra Aug 23, 2023
fdcc303
drop manual changes to the language files
tpokorra Aug 23, 2023
004cec5
fix for tests with AnswerMapper
tpokorra Aug 28, 2023
1c0b670
fix lint error
tpokorra Aug 28, 2023
c88c003
fix loading the answers in the browser
tpokorra Aug 28, 2023
b71b866
do not create a new answer but modify answer
tpokorra Aug 28, 2023
e620204
fix author in new file
tpokorra Aug 28, 2023
56f850e
fix return type of findByFormAndUser
tpokorra Aug 28, 2023
47d8e94
fix label for setting AllowEdit
tpokorra Aug 28, 2023
f2aaeeb
update answers instead of always adding new answers
tpokorra Aug 28, 2023
26c696b
refactoring: add new function storeAnswersForQuestion
tpokorra Aug 28, 2023
0eccc99
use specific api end point for updating submission
tpokorra Aug 28, 2023
a92477e
throw Exceptions on wrong insert or update of submission
tpokorra Aug 29, 2023
c163239
fix lint php issues
tpokorra Aug 29, 2023
5b74589
QuestionMultiple: load values for checkboxes and radio buttons from p…
tpokorra Aug 29, 2023
35e034a
fix for loading no answers on first submission
tpokorra Aug 29, 2023
4baa61c
add and implement delete button for form submission
tpokorra Aug 29, 2023
9f98978
update label in form settings
tpokorra Aug 29, 2023
28bacf3
fixes for Lint and Unit Tests
tpokorra Aug 30, 2023
ee64859
fixes for storing answers after rebase
tpokorra Aug 30, 2023
4a2f18a
fix lint issues
tpokorra Aug 31, 2023
5dea217
fix typos in php code from merge
tpokorra Aug 31, 2023
d29c325
fix unit tests
tpokorra Oct 23, 2023
45f6bed
fix more issues with unit tests and psalm tests
tpokorra Oct 23, 2023
c5b4e1d
fix according to coding standards
tpokorra Oct 24, 2023
02caa93
fix: creation of dynamic property OCA\Forms\Tests\Unit\Service\FormsS…
tpokorra Oct 24, 2023
d331578
The options allowEdit and submitMultiple exclude each other
tpokorra Oct 24, 2023
f91e79d
small fixes for previous commit, enabling and disabling options in re…
tpokorra Oct 24, 2023
226fe02
remove extra semicolon
tpokorra Oct 24, 2023
1124a97
add confirmation for deleting submission
tpokorra Oct 25, 2023
995d9c3
revert change to CHANGELOG.md
tpokorra Jan 5, 2024
d9dfa7c
fix space in DataStructure.md
tpokorra Jan 5, 2024
0dc0f79
fix comment block for function storeAnswersForQuestion
tpokorra Jan 5, 2024
cd1eafa
small fix for ApiController
tpokorra Jan 5, 2024
d60b3c4
another small fix for ApiController
tpokorra Jan 5, 2024
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
8 changes: 8 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,14 @@
'apiVersion' => 'v2(\.[1-3])?'
]
],
[
'name' => 'api#updateSubmission',
'url' => '/api/{apiVersion}/submission/update',
'verb' => 'POST',
'requirements' => [
'apiVersion' => 'v2(\.1)?'
]
],
[
'name' => 'api#deleteSubmission',
'url' => '/api/{apiVersion}/submission/{id}',
Expand Down
2 changes: 2 additions & 0 deletions docs/DataStructure.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ This document describes the Object-Structure, that is used within the Forms App
| expires | unix-timestamp | | When the form should expire. Timestamp `0` indicates _never_ |
| isAnonymous | Boolean | | If Answers will be stored anonymously |
| 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 @@ -37,6 +38,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
207 changes: 180 additions & 27 deletions lib/Controller/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

namespace OCA\Forms\Controller;

use OCA\Forms\Activity\ActivityManager;
use OCA\Forms\Constants;
use OCA\Forms\Db\Answer;
use OCA\Forms\Db\AnswerMapper;
Expand Down Expand Up @@ -70,6 +71,7 @@ public function __construct(
string $appName,
IRequest $request,
IUserSession $userSession,
private ActivityManager $activityManager,
private AnswerMapper $answerMapper,
private FormMapper $formMapper,
private OptionMapper $optionMapper,
Expand Down Expand Up @@ -212,6 +214,7 @@ public function newForm(): DataResponse {
'showToAllUsers' => false,
]);
$form->setSubmitMultiple(false);
$form->setAllowEdit(false);
$form->setShowExpiration(false);
$form->setExpires(0);
$form->setIsAnonymous(false);
Expand Down Expand Up @@ -958,39 +961,95 @@ public function getSubmissions(string $hash): DataResponse {
return new DataResponse($response);
}


/**
* Insert answers for a question
* @CORS
* @PublicCORSFix
* @NoAdminRequired
* @PublicPage
* Insert or update answers for a question
*
* @param int $submissionId
* @param array $question
* @param array $answerArray [arrayOfString]
* @param bool $update
*/
private function storeAnswersForQuestion($submissionId, array $question, array $answerArray) {
foreach ($answerArray as $answer) {
$answerText = '';
private function storeAnswersForQuestion($submissionId, array $question, array $answerArray, bool $update) {

// get stored answers for this question
$storedAnswers = [];
if ($update) {
$storedAnswers = $this->answerMapper->findBySubmissionAndQuestion($submissionId, $question['id']);
}

// Are we using answer ids as values
if (in_array($question['type'], Constants::ANSWER_TYPES_PREDEFINED)) {

$newAnswerTexts = array();

// We are using answer ids as values
// collect names of options
foreach ($answerArray as $answer) {
$answerText = "";

// Are we using answer ids as values
if (in_array($question['type'], Constants::ANSWER_TYPES_PREDEFINED)) {
// Search corresponding option, skip processing if not found
$optionIndex = array_search($answer, array_column($question['options'], 'id'));
if ($optionIndex !== false) {
$answerText = $question['options'][$optionIndex]['text'];
} elseif (!empty($question['extraSettings']['allowOtherAnswer']) && strpos($answer, Constants::QUESTION_EXTRASETTINGS_OTHER_PREFIX) === 0) {
$answerText = str_replace(Constants::QUESTION_EXTRASETTINGS_OTHER_PREFIX, "", $answer);
}
} else {
$answerText = $answer; // Not a multiple-question, answerText is given answer

$newAnswerTexts[] = $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);
}
}

if ($answerText === "") {
continue;
// drop all answers that are not in new set of answers
foreach($storedAnswers as $storedAnswer) {
if (empty($newAnswerTexts) || !in_array($storedAnswer->getText(), $newAnswerTexts)) {
$this->answerMapper->delete($storedAnswer);
}
}
} else {
// just one answer
$answerText = $answerArray[0]; // Not a multiple-question, answerText is given answer

if (!empty($storedAnswers)) {
$answerEntity = $storedAnswers[0];
$answerEntity->setText($answerText);
$this->answerMapper->update($answerEntity);
} else {
if ($answerText === "") {
return;
}

$answerEntity = new Answer();
$answerEntity->setSubmissionId($submissionId);
$answerEntity->setQuestionId($question['id']);
$answerEntity->setText($answerText);
$this->answerMapper->insert($answerEntity);
$answerEntity = new Answer();
$answerEntity->setSubmissionId($submissionId);
$answerEntity->setQuestionId($question['id']);
$answerEntity->setText($answerText);
$this->answerMapper->insert($answerEntity);
}
}
}

Expand All @@ -1000,22 +1059,16 @@ private function storeAnswersForQuestion($submissionId, array $question, array $
* @NoAdminRequired
* @PublicPage
*
* Process a new submission
* check a submission and return some required data objects
*
* @param int $formId the form id
* @param array $answers [question_id => arrayOfString]
* @param string $shareHash public share-hash -> Necessary to submit on public link-shares.
* @return DataResponse
* @return array
* @throws OCSBadRequestException
* @throws OCSForbiddenException
*/
public function insertSubmission(int $formId, array $answers, string $shareHash = ''): DataResponse {
$this->logger->debug('Inserting submission: formId: {formId}, answers: {answers}, shareHash: {shareHash}', [
'formId' => $formId,
'answers' => $answers,
'shareHash' => $shareHash,
]);

private function checkAndPrepareSubmission(int $formId, array $answers, string $shareHash = ''): array {
try {
$form = $this->formMapper->findById($formId);
$questions = $this->formsService->getQuestions($formId);
Expand Down Expand Up @@ -1065,6 +1118,102 @@ public function insertSubmission(int $formId, array $answers, string $shareHash
throw new OCSBadRequestException('At least one submitted answer is not valid');
}

return array($form, $questions);
}

/**
* @CORS
* @PublicCORSFix
* @NoAdminRequired
* @PublicPage
*
* Update an existing submission
*
* @param int $formId the form id
* @param array $answers [question_id => arrayOfString]
* @param string $shareHash public share-hash -> Necessary to submit on public link-shares.
* @return DataResponse
* @throws OCSBadRequestException
* @throws OCSForbiddenException
*/
public function updateSubmission(int $formId, array $answers, string $shareHash = ''): DataResponse {
$this->logger->debug('Updating submission: formId: {formId}, answers: {answers}, shareHash: {shareHash}', [
'formId' => $formId,
'answers' => $answers,
'shareHash' => $shareHash,
]);

list($form, $questions) = $this->checkAndPrepareSubmission($formId, $answers, $shareHash);

// if edit is allowed get existing submission of this user
if ($form->getAllowEdit() && $this->currentUser) {
try {
$submission = $this->submissionMapper->findByFormAndUser($form->getId(), $this->currentUser->getUID());
} catch (DoesNotExistException $e) {
throw new OCSBadRequestException('Cannot update a non existing submission');
}
} else {
throw new OCSBadRequestException('Can only update if AllowEdit is set');
}

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

// 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;
}

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

$this->formsService->setLastUpdatedTimestamp($formId);

//Create Activity
$this->activityManager->publishNewSubmission($form, $submission->getUserId());

return new DataResponse();
}

/**
* @CORS
* @PublicCORSFix
* @NoAdminRequired
* @PublicPage
*
* Process a new submission
*
* @param int $formId the form id
* @param array $answers [question_id => arrayOfString]
* @param string $shareHash public share-hash -> Necessary to submit on public link-shares.
* @return DataResponse
* @throws OCSBadRequestException
* @throws OCSForbiddenException
*/
public function insertSubmission(int $formId, array $answers, string $shareHash = ''): DataResponse {
$this->logger->debug('Inserting submission: formId: {formId}, answers: {answers}, shareHash: {shareHash}', [
'formId' => $formId,
'answers' => $answers,
'shareHash' => $shareHash,
]);

list($form, $questions) = $this->checkAndPrepareSubmission($formId, $answers, $shareHash);

// if edit is allowed then do not allow inserting another submission if one exists already
if ($form->getAllowEdit() && $this->currentUser) {
try {
$submission = $this->submissionMapper->findByFormAndUser($form->getId(), $this->currentUser->getUID());

// we do not want to add another submission
throw new OCSForbiddenException('Do not insert another submission');
} catch (DoesNotExistException $e) {
// all is good
}
}

// Create Submission
$submission = new Submission();
$submission->setFormId($formId);
Expand All @@ -1080,7 +1229,6 @@ public function insertSubmission(int $formId, array $answers, string $shareHash

// Insert new submission
$this->submissionMapper->insert($submission);
$submissionId = $submission->getId();

// Process Answers
foreach ($answers as $questionId => $answerArray) {
Expand All @@ -1090,7 +1238,7 @@ public function insertSubmission(int $formId, array $answers, string $shareHash
continue;
}

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

$this->formsService->setLastUpdatedTimestamp($formId);
Expand Down Expand Up @@ -1125,8 +1273,13 @@ public function deleteSubmission(int $id): DataResponse {
throw new OCSBadRequestException();
}

$canDeleteSubmission = false;
if ($form->getAllowEdit() && $submission->getUserId() == $this->currentUser->getUID()) {
$canDeleteSubmission = true;
}

// The current user has permissions to remove submissions
if (!$this->formsService->canDeleteResults($form)) {
if (!$canDeleteSubmission && !$this->formsService->canDeleteResults($form)) {
$this->logger->debug('This form is not owned by the current user and user has no `results_delete` permission');
throw new OCSForbiddenException();
}
Expand Down
20 changes: 20 additions & 0 deletions lib/Db/AnswerMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,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
5 changes: 5 additions & 0 deletions lib/Db/Form.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
* @method void setIsAnonymous(bool $value)
* @method integer getSubmitMultiple()
* @method void setSubmitMultiple(bool $value)
* @method integer getAllowEdit()
* @method void setAllowEdit(bool $value)
* @method integer getShowExpiration()
* @method void setShowExpiration(bool $value)
* @method integer getLastUpdated()
Expand All @@ -65,6 +67,7 @@ class Form extends Entity {
protected $expires;
protected $isAnonymous;
protected $submitMultiple;
protected $allowEdit;
protected $showExpiration;
protected $submissionMessage;
protected $lastUpdated;
Expand All @@ -77,6 +80,7 @@ public function __construct() {
$this->addType('expires', 'integer');
$this->addType('isAnonymous', 'bool');
$this->addType('submitMultiple', 'bool');
$this->addType('allowEdit', 'bool');
$this->addType('showExpiration', 'bool');
$this->addType('lastUpdated', 'integer');
}
Expand Down Expand Up @@ -104,6 +108,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
Loading
Loading