Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
29fa01d
Renaming supplementary files to exercise files (preparing for supplem…
krulis-martin Nov 12, 2025
bd9be31
Adding optional random-generated IDs for anonymous access to uploaded…
krulis-martin Nov 13, 2025
175e2e2
Removing previous modification of uploaded file (external links need …
krulis-martin Nov 13, 2025
7a863d0
Adding exercise file link entity and repository in model.
krulis-martin Nov 14, 2025
f493455
Removing isPublic flag from uploaded file entity. Access to files wil…
krulis-martin Nov 14, 2025
871e6a8
Improving exercise links and generating migration.
krulis-martin Nov 14, 2025
2615ce5
Properly copying exercise file links when assignment is created or fi…
krulis-martin Nov 14, 2025
ac93519
Implementing endpoints for exercise file links manipulation.
krulis-martin Nov 15, 2025
8364d97
Adding key-fileId mapping (from exercise file links) to exercise and …
krulis-martin Nov 15, 2025
f620013
Updating exercise file link interactions with other entities and upda…
krulis-martin Nov 15, 2025
816da6e
Updating tests to cover latest changes in views, fixing bugs.
krulis-martin Nov 16, 2025
931a54b
Adding tests for basic CRUD operations on exercise file links.
krulis-martin Nov 17, 2025
dda336b
Adding tests for new download (via link) endpoints and fixing related…
krulis-martin Nov 17, 2025
fafc134
Updating tests to check for proper file link duplication/updates, whe…
krulis-martin Nov 17, 2025
10b169c
Creating a command for attachment files conversion.
krulis-martin Nov 21, 2025
de61d31
Adding cookie-based authentication (as configurable option) so the fi…
krulis-martin Nov 27, 2025
4491a69
Fixing annotations for UUID values in endpoints.
krulis-martin Nov 28, 2025
07da7f3
Adding checks to key input for create/update exercise file link endpo…
krulis-martin Dec 3, 2025
6de89bc
Enabling partial assignment sync from the exercise.
krulis-martin Dec 6, 2025
0f873b8
Adding test for partial assignment sync.
krulis-martin Dec 6, 2025
427a010
Moving updates of localized texts of an assignment to a separate endp…
krulis-martin Dec 6, 2025
862985a
Fixing detection in changes of exercise-assignment file links for syn…
krulis-martin Dec 9, 2025
3289c63
Fixing problem with exercise file links removal during exercise-assig…
krulis-martin Dec 10, 2025
7f9644b
Fixing exercise files cleanup command so that linked files are not de…
krulis-martin Dec 10, 2025
cadf70c
Improving swagger OAPI generator to mark deprecated endpoint and gene…
krulis-martin Dec 10, 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
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ public function checkEditComment(string $id, string $commentId)
required: false,
)]
#[Path("id", new VUuid(), "identifier of the solution", required: true)]
#[Path("commentId", new VString(), "identifier of the review comment", required: true)]
#[Path("commentId", new VUuid(), "identifier of the review comment", required: true)]
public function actionEditComment(string $id, string $commentId)
{
$solution = $this->assignmentSolutions->findOrThrow($id);
Expand Down Expand Up @@ -407,7 +407,7 @@ public function checkDeleteComment(string $id, string $commentId)
* @DELETE
*/
#[Path("id", new VUuid(), "identifier of the solution", required: true)]
#[Path("commentId", new VString(), "identifier of the review comment", required: true)]
#[Path("commentId", new VUuid(), "identifier of the review comment", required: true)]
public function actionDeleteComment(string $id, string $commentId)
{
$comment = $this->reviewComments->findOrThrow($commentId);
Expand Down
145 changes: 97 additions & 48 deletions app/V1Module/presenters/AssignmentsPresenter.php
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,11 @@ public function checkUpdateDetail(string $id)
*/
#[Post("version", new VInt(), "Version of the edited assignment")]
#[Post("isPublic", new VBool(), "Is the assignment ready to be displayed to students?")]
#[Post("localizedTexts", new VArray(), "A description of the assignment")]
#[Post(
"localizedStudentHints",
new VArray(),
"Additional localized hint texts for students (locale => hint text)",
)]
#[Post("firstDeadline", new VTimestamp(), "First deadline for submission of the assignment")]
#[Post(
"maxPointsBeforeFirstDeadline",
Expand Down Expand Up @@ -307,10 +311,6 @@ public function actionUpdateDetail(string $id)
);
}

// localized texts cannot be empty
if (count($req->getPost("localizedTexts")) == 0) {
throw new InvalidApiArgumentException('localizedTexts', "No entry for localized texts given.");
}

if ($this->isRequestJson()) {
$disabledRuntimeIds = $req->getPost("disabledRuntimeEnvironmentIds");
Expand Down Expand Up @@ -425,49 +425,17 @@ public function actionUpdateDetail(string $id)
$this->solutionEvaluations->flush();
}

// go through localizedTexts and construct database entities
$localizedTexts = [];
// go through localized hints and construct database entities
$localizedAssignments = [];
foreach ($req->getPost("localizedTexts") as $localization) {
$lang = $localization["locale"];

if (array_key_exists($lang, $localizedTexts)) {
throw new InvalidApiArgumentException('localizedTexts', "Duplicate entry for language '$lang'");
}

// create all new localized texts
$assignmentExercise = $assignment->getExercise();
$localizedExercise = $assignmentExercise ? $assignmentExercise->getLocalizedTextByLocale($lang) : null;
$externalAssignmentLink = trim(Arrays::get($localization, "link", ""));
if ($externalAssignmentLink !== "" && !Validators::isUrl($externalAssignmentLink)) {
throw new InvalidApiArgumentException('link', "External assignment link is not a valid URL");
}

$localizedTexts[$lang] = new LocalizedExercise(
foreach ($req->getPost("localizedStudentHints") ?? [] as $lang => $hintText) {
$localizedAssignments[$lang] = new LocalizedAssignment(
$lang,
trim(Arrays::get($localization, "name", "")),
trim(Arrays::get($localization, "text", "")),
$localizedExercise ? $localizedExercise->getDescription() : "",
$externalAssignmentLink ?: null
trim($hintText)
);

if (array_key_exists("studentHint", $localization)) {
$localizedAssignments[$lang] = new LocalizedAssignment(
$lang,
trim(Arrays::get($localization, "studentHint", ""))
);
}
}

// make changes to database
Localizations::updateCollection($assignment->getLocalizedTexts(), $localizedTexts);

foreach ($assignment->getLocalizedTexts() as $localizedText) {
$this->assignments->persist($localizedText, false);
}

// save changes to database (if any)
Localizations::updateCollection($assignment->getLocalizedAssignments(), $localizedAssignments);

foreach ($assignment->getLocalizedAssignments() as $localizedAssignment) {
$this->assignments->persist($localizedAssignment, false);
}
Expand Down Expand Up @@ -501,7 +469,81 @@ public function actionUpdateDetail(string $id)
}
}

$this->assignments->flush();
$this->assignments->persist($assignment);
$this->sendSuccessResponse($this->assignmentViewFactory->getAssignment($assignment));
}

public function checkUpdateLocalizedTexts(string $id)
{
$assignment = $this->assignments->findOrThrow($id);
if (!$this->assignmentAcl->canUpdate($assignment)) {
throw new ForbiddenRequestException("You cannot update this assignment.");
}
}

/**
* Update (only) the localized texts of an assignment.
* This is a separate operations since the texts are taken over from the exercise.
* Updating them is an override of the exercise specification and needs to be handled carefully.
* @POST
*/
#[Post("version", new VInt(), "Version of the edited assignment")]
#[Post("localizedTexts", new VArray(), "Localized texts with exercise/assignment specification")]
#[Path("id", new VUuid(), "Identifier of the updated assignment", required: true)]
public function actionUpdateLocalizedTexts(string $id)
{
$assignment = $this->assignments->findOrThrow($id);

$req = $this->getRequest();
$version = (int)$req->getPost("version");
if ($version !== $assignment->getVersion()) {
$newVer = $assignment->getVersion();
throw new BadRequestException(
"The assignment was edited in the meantime and the version has changed. Current version is $newVer.",
FrontendErrorMappings::E400_010__ENTITY_VERSION_TOO_OLD,
[
'entity' => 'assignment',
'id' => $id,
'version' => $newVer
]
);
}

// go through localizedTexts and construct database entities
$localizedTexts = [];
foreach ($req->getPost("localizedTexts") as $localization) {
$lang = $localization["locale"];

if (array_key_exists($lang, $localizedTexts)) {
throw new InvalidApiArgumentException('localizedTexts', "Duplicate entry for language '$lang'");
}

// create all new localized texts
$assignmentExercise = $assignment->getExercise();
$localizedExercise = $assignmentExercise ? $assignmentExercise->getLocalizedTextByLocale($lang) : null;
$externalAssignmentLink = trim(Arrays::get($localization, "link", ""));
if ($externalAssignmentLink !== "" && !Validators::isUrl($externalAssignmentLink)) {
throw new InvalidApiArgumentException('link', "External assignment link is not a valid URL");
}

$localizedTexts[$lang] = new LocalizedExercise(
$lang,
trim(Arrays::get($localization, "name", "")),
trim(Arrays::get($localization, "text", "")),
$localizedExercise ? $localizedExercise->getDescription() : "",
$externalAssignmentLink ?: null
);
}

// make changes to database
Localizations::updateCollection($assignment->getLocalizedTexts(), $localizedTexts);
foreach ($assignment->getLocalizedTexts() as $localizedText) {
$this->assignments->persist($localizedText, false);
}

$assignment->incrementVersion();
$assignment->updatedNow();
$this->assignments->persist($assignment);
$this->sendSuccessResponse($this->assignmentViewFactory->getAssignment($assignment));
}

Expand Down Expand Up @@ -543,8 +585,8 @@ public function actionValidate($id)
* @throws InvalidStateException
* @throws NotFoundException
*/
#[Post("exerciseId", new VMixed(), "Identifier of the exercise", nullable: true)]
#[Post("groupId", new VMixed(), "Identifier of the group", nullable: true)]
#[Post("exerciseId", new VUuid(), "Identifier of the exercise", nullable: true)]
#[Post("groupId", new VUuid(), "Identifier of the group", nullable: true)]
public function actionCreate()
{
$req = $this->getRequest();
Expand Down Expand Up @@ -645,10 +687,17 @@ public function checkSyncWithExercise(string $id)
* @throws NotFoundException
*/
#[Path("id", new VUuid(), "Identifier of the assignment that should be synchronized", required: true)]
#[Post("syncOptions", new VArray(new VString(
1,
32,
'/^(configurationType|exerciseConfig|exerciseEnvironmentConfigs|exerciseTests|files|fileLinks|hardwareGroups'
. '|limits|localizedTexts|mergeJudgeLogs|runtimeEnvironments|scoreConfig)$/'
)), "List of options what to synchronize (if missing, everything is synchronized)", required: false)]
public function actionSyncWithExercise($id)
{
$assignment = $this->assignments->findOrThrow($id);
$exercise = $assignment->getExercise();
$syncOptions = $this->getRequest()->getPost("syncOptions") ?? [];

if ($exercise === null) {
throw new NotFoundException("Exercise for assignment '{$id}' was deleted");
Expand All @@ -661,7 +710,7 @@ public function actionSyncWithExercise($id)
}

$assignment->updatedNow();
$assignment->syncWithExercise();
$assignment->syncWithExercise($syncOptions);
$this->assignments->flush();
$this->sendSuccessResponse($this->assignmentViewFactory->getAssignment($assignment));
}
Expand Down Expand Up @@ -713,7 +762,7 @@ public function checkUserSolutions(string $id, string $userId)
* @GET
*/
#[Path("id", new VUuid(), "Identifier of the assignment", required: true)]
#[Path("userId", new VString(), "Identifier of the user", required: true)]
#[Path("userId", new VUuid(), "Identifier of the user", required: true)]
public function actionUserSolutions(string $id, string $userId)
{
$assignment = $this->assignments->findOrThrow($id);
Expand Down Expand Up @@ -757,7 +806,7 @@ public function checkBestSolution(string $id, string $userId)
* @throws ForbiddenRequestException
*/
#[Path("id", new VUuid(), "Identifier of the assignment", required: true)]
#[Path("userId", new VString(), "Identifier of the user", required: true)]
#[Path("userId", new VUuid(), "Identifier of the user", required: true)]
public function actionBestSolution(string $id, string $userId)
{
$assignment = $this->assignments->findOrThrow($id);
Expand Down
14 changes: 7 additions & 7 deletions app/V1Module/presenters/CommentsPresenter.php
Original file line number Diff line number Diff line change
Expand Up @@ -180,12 +180,12 @@ public function checkTogglePrivate(string $threadId, string $commentId)

/**
* Make a private comment public or vice versa
* @DEPRECATED
* @deprecated
* @POST
* @throws NotFoundException
*/
#[Path("threadId", new VString(), "Identifier of the comment thread", required: true)]
#[Path("commentId", new VString(), "Identifier of the comment", required: true)]
#[Path("threadId", new VUuid(), "Identifier of the comment thread", required: true)]
#[Path("commentId", new VUuid(), "Identifier of the comment", required: true)]
public function actionTogglePrivate(string $threadId, string $commentId)
{
/** @var Comment $comment */
Expand Down Expand Up @@ -218,8 +218,8 @@ public function checkSetPrivate(string $threadId, string $commentId)
* @throws NotFoundException
*/
#[Post("isPrivate", new VBool(), "True if the comment is private")]
#[Path("threadId", new VString(), "Identifier of the comment thread", required: true)]
#[Path("commentId", new VString(), "Identifier of the comment", required: true)]
#[Path("threadId", new VUuid(), "Identifier of the comment thread", required: true)]
#[Path("commentId", new VUuid(), "Identifier of the comment", required: true)]
public function actionSetPrivate(string $threadId, string $commentId)
{
/** @var Comment $comment */
Expand Down Expand Up @@ -255,8 +255,8 @@ public function checkDelete(string $threadId, string $commentId)
* @throws ForbiddenRequestException
* @throws NotFoundException
*/
#[Path("threadId", new VString(), "Identifier of the comment thread", required: true)]
#[Path("commentId", new VString(), "Identifier of the comment", required: true)]
#[Path("threadId", new VUuid(), "Identifier of the comment thread", required: true)]
#[Path("commentId", new VUuid(), "Identifier of the comment", required: true)]
public function actionDelete(string $threadId, string $commentId)
{
/** @var Comment $comment */
Expand Down
3 changes: 2 additions & 1 deletion app/V1Module/presenters/EmailsPresenter.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use App\Exceptions\NotFoundException;
use App\Helpers\EmailHelper;
use App\Helpers\Emails\EmailLocalizationHelper;
use App\Helpers\MetaFormats\Validators\VUuid;
use App\Model\Entity\User;
use App\Model\Repository\Groups;
use App\Security\ACL\IEmailPermissions;
Expand Down Expand Up @@ -166,7 +167,7 @@ public function checkSendToGroupMembers(string $groupId)
#[Post("toMe", new VBool(), "User wants to also receive an email")]
#[Post("subject", new VString(1), "Subject for the soon to be sent email")]
#[Post("message", new VString(1), "Message which will be sent, can be html code")]
#[Path("groupId", new VString(), required: true)]
#[Path("groupId", new VUuid(), required: true)]
public function actionSendToGroupMembers(string $groupId)
{
$user = $this->getCurrentUser();
Expand Down
Loading