From 450b861f02a204e746805efba8d89464189454ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Thu, 8 Jan 2026 18:43:11 +0100 Subject: [PATCH 1/2] Adding endpoint for creating term groups. --- app/helpers/Recodex/RecodexApiHelper.php | 50 ++++++++ app/helpers/Recodex/RecodexGroup.php | 18 ++- app/presenters/GroupsPresenter.php | 59 ++++++++++ app/router/RouterFactory.php | 1 + app/security/ACL/IGroupPermissions.php | 2 + tests/Presenters/GroupsPresenter.phpt | 138 +++++++++++++++++++++++ 6 files changed, 263 insertions(+), 5 deletions(-) diff --git a/app/helpers/Recodex/RecodexApiHelper.php b/app/helpers/Recodex/RecodexApiHelper.php index 7537ea6..6057ee9 100644 --- a/app/helpers/Recodex/RecodexApiHelper.php +++ b/app/helpers/Recodex/RecodexApiHelper.php @@ -10,6 +10,7 @@ use GuzzleHttp; use Nette\Utils\Arrays; use Tracy\Debugger; +use InvalidArgumentException; /** * Wrapper for ReCodEx API calls. @@ -453,4 +454,53 @@ public function createGroup(SisScheduleEvent $event, string $parentGroupId, User return null; } + + /** + * Create a new organizational group for a semester. + * @param string $instanceId ID of the instance where the group is being created + * @param string $parentGroupId ID of the parent group + * @param string $term semester identifier (e.g. "2025-2") + * @param array $texts localized texts for the group (locale => ['name' => ..., 'description' => ...]) + * @return string|null ID of the created group or null on failure + * @throws InvalidArgumentException + */ + public function createTermGroup(string $instanceId, string $parentGroupId, string $term, array $texts): ?string + { + Debugger::log("ReCodEx::createTermGroup('$parentGroupId')", Debugger::INFO); + + $localizedTexts = []; + foreach (['en', 'cs'] as $locale) { + if ( + !array_key_exists($locale, $texts) || + !array_key_exists('name', $texts[$locale]) || + !array_key_exists('description', $texts[$locale]) + ) { + throw new InvalidArgumentException("Localized texts for locale '$locale' are missing."); + } + $localizedTexts[] = [ + 'locale' => $locale, + 'name' => $texts[$locale]['name'], + 'description' => $texts[$locale]['description'], + ]; + } + + $group = $this->post("groups", [], [ + 'instanceId' => $instanceId, + 'parentGroupId' => $parentGroupId, + 'publicStats' => false, + 'detaining' => false, + 'isPublic' => false, + 'isOrganizational' => true, + 'isExam' => false, + 'noAdmin' => true, + 'localizedTexts' => $localizedTexts, + ]); + + if ($group && !empty($group['id'])) { + $this->addAttribute($group['id'], RecodexGroup::ATTR_TERM_KEY, $term); + return $group['id']; + } + + return null; + } } diff --git a/app/helpers/Recodex/RecodexGroup.php b/app/helpers/Recodex/RecodexGroup.php index 54a8a94..dd8e2d6 100644 --- a/app/helpers/Recodex/RecodexGroup.php +++ b/app/helpers/Recodex/RecodexGroup.php @@ -236,22 +236,30 @@ public function jsonSerialize(): array /* * Public helper methods (data getters) */ - public function hasAttribute(string $key, string $value): bool + + /** + * Checks whether the group has the specified attribute (with optional value). + * @param string $key Attribute key + * @param string|null $value Optional attribute value to check for (null means any value for the key) + * @return bool True if the attribute (with specified value) is present, false otherwise + */ + public function hasAttribute(string $key, ?string $value = null): bool { - return in_array($value, $this->attributes[$key] ?? [], true); + return $value === null ? (count($this->attributes[$key] ?? []) > 0) + : in_array($value, $this->attributes[$key] ?? [], true); } - public function hasGroupAttribute($groupId) + public function hasGroupAttribute(?string $groupId = null): bool { return $this->hasAttribute(self::ATTR_GROUP_KEY, $groupId); } - public function hasTermAttribute($term) + public function hasTermAttribute(?string $term = null): bool { return $this->hasAttribute(self::ATTR_TERM_KEY, $term); } - public function hasCourseAttribute($courseId) + public function hasCourseAttribute(?string $courseId = null): bool { return $this->hasAttribute(self::ATTR_COURSE_KEY, $courseId); } diff --git a/app/presenters/GroupsPresenter.php b/app/presenters/GroupsPresenter.php index 26ac3a3..b15d581 100644 --- a/app/presenters/GroupsPresenter.php +++ b/app/presenters/GroupsPresenter.php @@ -55,6 +55,29 @@ private function isGroupSuitableForEvent(array $groups, string $groupId, SisSche } } + private function isGroupSuitableForTerm(array $groups, string $groupId, string $term): void + { + if (empty($groups[$groupId])) { + throw new NotFoundException("Group $groupId does not exist or is not accessible by the user."); + } + + $group = $groups[$groupId]; + if (!$group->hasCourseAttribute()) { + throw new ForbiddenRequestException("Group $groupId does not have any course attributes."); + } + if ($group->hasTermAttribute()) { + throw new ForbiddenRequestException("Group $groupId have term attributes."); + } + + foreach ($groups as $group) { + if ($group->parentGroupId === $groupId && $group->hasTermAttribute($term)) { + throw new ForbiddenRequestException( + "One of the children of group $groupId already have associated term $term." + ); + } + } + } + private function canUserAdministrateGroup(array $groups, string $groupId): void { if (empty($groups[$groupId])) { @@ -165,6 +188,42 @@ public function actionCreate(string $parentId, string $eventId) $this->sendSuccessResponse("OK"); } + public function checkCreateTerm(string $parentId, string $term) + { + if (!$this->groupAcl->canCreateTermGroup()) { + throw new ForbiddenRequestException("You do not have permissions to create term groups."); + } + + $groups = $this->recodexApi->getGroups($this->getCurrentUser()); + $this->isGroupSuitableForTerm($groups, $parentId, $term); // throws exception if not suitable + } + + /** + * Proxy to ReCodEx that creates a new organizational group for a term. + * @POST + * @Param(type="query", name="parentId", validation="string:1..", + * description="ReCodEx ID of a group that will be the parent group.") + * @Param(type="query", name="term", validation="string:6", + * description="Term for which the organizational group will be created (e.g. '2025-2').") + * @Param(type="post", name="texts", validation="array", + * description="Localized texts for the group (locale => ['name' => ..., 'description' => ...]).") + */ + public function actionCreateTerm(string $parentId, string $term) + { + $texts = $this->getRequest()->getPost('texts'); + foreach (['en', 'cs'] as $locale) { + if ( + !array_key_exists($locale, $texts) || + !array_key_exists('name', $texts[$locale]) || + !array_key_exists('description', $texts[$locale]) + ) { + throw new BadRequestException("Localized texts for locale '$locale' are missing."); + } + } + $this->recodexApi->createTermGroup($this->getCurrentUser()->getInstanceId(), $parentId, $term, $texts); + $this->sendSuccessResponse("OK"); + } + public function checkBind(string $id, string $eventId) { $event = $this->sisEvents->findOrThrow($eventId); diff --git a/app/router/RouterFactory.php b/app/router/RouterFactory.php index c4a5e94..96f1fb0 100644 --- a/app/router/RouterFactory.php +++ b/app/router/RouterFactory.php @@ -82,6 +82,7 @@ private static function createGroupsRoutes(string $prefix): RouteList $router[] = new PostRoute("$prefix//join", "Groups:join"); $router[] = new PostRoute("$prefix//add-attribute", "Groups:addAttribute"); $router[] = new PostRoute("$prefix//remove-attribute", "Groups:removeAttribute"); + $router[] = new PostRoute("$prefix//create-term/", "Groups:createTerm"); return $router; } } diff --git a/app/security/ACL/IGroupPermissions.php b/app/security/ACL/IGroupPermissions.php index ab29fff..8bd7a96 100644 --- a/app/security/ACL/IGroupPermissions.php +++ b/app/security/ACL/IGroupPermissions.php @@ -11,4 +11,6 @@ public function canViewStudent(): bool; public function canViewTeacher(): bool; public function canEditRawAttributes(): bool; + + public function canCreateTermGroup(): bool; } diff --git a/tests/Presenters/GroupsPresenter.phpt b/tests/Presenters/GroupsPresenter.phpt index ed52436..d5635dc 100644 --- a/tests/Presenters/GroupsPresenter.phpt +++ b/tests/Presenters/GroupsPresenter.phpt @@ -803,6 +803,144 @@ class TestGroupsPresenter extends Tester\TestCase }, ForbiddenRequestException::class); } + public function testCreateTermGroup() + { + PresenterTestHelper::loginDefaultAdmin($this->container); + $user = $this->users->findOneBy(['email' => PresenterTestHelper::TEACHER1_LOGIN]); + Assert::notNull($user); + $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); + Assert::notNull($event); + + $texts = [ + 'cs' => ['name' => 'Skupina', 'description' => 'Popis skupiny'], + 'en' => ['name' => 'Group', 'description' => 'Group description'], + ]; + + $this->client->shouldReceive("get")->with('group-attributes', Mockery::any()) + ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ + 'success' => true, + 'code' => 200, + 'payload' => [ + self::group('r', null, 'Root', true, []), + self::group('c1', 'r', 'Course group', true, [RecodexGroup::ATTR_COURSE_KEY => [$event->getCourse()->getCode()]]), + ] + ]))); + + $this->client->shouldReceive("post")->with('groups', Mockery::on(function ($arg) use ($user, $texts) { + Assert::type('array', $arg); + Assert::type('array', $arg['json'] ?? null); + $body = $arg['json']; + Assert::equal($user->getInstanceId(), $body['instanceId']); + Assert::equal('c1', $body['parentGroupId']); + Assert::false($body['publicStats']); + Assert::false($body['detaining']); + Assert::false($body['isPublic']); + Assert::true($body['isOrganizational']); + Assert::false($body['isExam']); + Assert::true($body['noAdmin']); + Assert::count(2, $body['localizedTexts']); + foreach ($body['localizedTexts'] as $localizedText) { + Assert::type('array', $localizedText); + Assert::count(3, $localizedText); + $locale = $localizedText['locale'] ?? ''; + Assert::contains($locale, ['en', 'cs']); + Assert::equal($texts[$locale]['name'], $localizedText['name'] ?? null); + Assert::equal($texts[$locale]['description'], $localizedText['description'] ?? null); + } + return true; + }))->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ + 'success' => true, + 'code' => 200, + 'payload' => ['id' => 'g1'] + ]))); + + $this->client->shouldReceive("post")->with('group-attributes/g1', Mockery::any()) + ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ + 'success' => true, + 'code' => 200, + 'payload' => "OK" + ]))); + + $payload = PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'Groups', + 'POST', + ['action' => 'createTerm', 'parentId' => 'c1', 'term' => '2025-1'], + ['texts' => $texts] + ); + + Assert::equal("OK", $payload); + } + + public function testCreateTermGroupWrongParent() + { + PresenterTestHelper::loginDefaultAdmin($this->container); + $user = $this->users->findOneBy(['email' => PresenterTestHelper::TEACHER1_LOGIN]); + Assert::notNull($user); + $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); + Assert::notNull($event); + + $texts = [ + 'cs' => ['name' => 'Skupina', 'description' => 'Popis skupiny'], + 'en' => ['name' => 'Group', 'description' => 'Group description'], + ]; + + $this->client->shouldReceive("get")->with('group-attributes', Mockery::any()) + ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ + 'success' => true, + 'code' => 200, + 'payload' => [ + self::group('r', null, 'Root', true, []), + self::group('c1', 'r', 'Course group', true, []), + ] + ]))); + + Assert::exception(function () use ($texts) { + PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'Groups', + 'POST', + ['action' => 'createTerm', 'parentId' => 'c1', 'term' => '2025-1'], + ['texts' => $texts] + ); + }, ForbiddenRequestException::class); + } + + public function testCreateTermGroupAlreadyExist() + { + PresenterTestHelper::loginDefaultAdmin($this->container); + $user = $this->users->findOneBy(['email' => PresenterTestHelper::TEACHER1_LOGIN]); + Assert::notNull($user); + $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); + Assert::notNull($event); + + $texts = [ + 'cs' => ['name' => 'Skupina', 'description' => 'Popis skupiny'], + 'en' => ['name' => 'Group', 'description' => 'Group description'], + ]; + + $this->client->shouldReceive("get")->with('group-attributes', Mockery::any()) + ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ + 'success' => true, + 'code' => 200, + 'payload' => [ + self::group('r', null, 'Root', true, []), + self::group('c1', 'r', 'Course group', true, [RecodexGroup::ATTR_COURSE_KEY => [$event->getCourse()->getCode()]]), + self::group('t1', 'c1', 'Term group', true, [RecodexGroup::ATTR_TERM_KEY => [$event->getTerm()->getYearTermKey()]]), + ] + ]))); + + Assert::exception(function () use ($texts) { + PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'Groups', + 'POST', + ['action' => 'createTerm', 'parentId' => 'c1', 'term' => '2025-1'], + ['texts' => $texts] + ); + }, ForbiddenRequestException::class); + } + public function testAddAttribute() { PresenterTestHelper::loginDefaultAdmin($this->container); From 4a34ed0802e6e39e4600a6c380a8f744eb67bf5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Tue, 13 Jan 2026 00:14:05 +0100 Subject: [PATCH 2/2] Fixing Bad Request response for term group creation. --- app/presenters/GroupsPresenter.php | 6 +++--- tests/Presenters/GroupsPresenter.phpt | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/presenters/GroupsPresenter.php b/app/presenters/GroupsPresenter.php index b15d581..deff7c0 100644 --- a/app/presenters/GroupsPresenter.php +++ b/app/presenters/GroupsPresenter.php @@ -63,15 +63,15 @@ private function isGroupSuitableForTerm(array $groups, string $groupId, string $ $group = $groups[$groupId]; if (!$group->hasCourseAttribute()) { - throw new ForbiddenRequestException("Group $groupId does not have any course attributes."); + throw new BadRequestException("Group $groupId does not have any course attributes."); } if ($group->hasTermAttribute()) { - throw new ForbiddenRequestException("Group $groupId have term attributes."); + throw new BadRequestException("Group $groupId have term attributes."); } foreach ($groups as $group) { if ($group->parentGroupId === $groupId && $group->hasTermAttribute($term)) { - throw new ForbiddenRequestException( + throw new BadRequestException( "One of the children of group $groupId already have associated term $term." ); } diff --git a/tests/Presenters/GroupsPresenter.phpt b/tests/Presenters/GroupsPresenter.phpt index d5635dc..63356cd 100644 --- a/tests/Presenters/GroupsPresenter.phpt +++ b/tests/Presenters/GroupsPresenter.phpt @@ -903,7 +903,7 @@ class TestGroupsPresenter extends Tester\TestCase ['action' => 'createTerm', 'parentId' => 'c1', 'term' => '2025-1'], ['texts' => $texts] ); - }, ForbiddenRequestException::class); + }, BadRequestException::class); } public function testCreateTermGroupAlreadyExist() @@ -938,7 +938,7 @@ class TestGroupsPresenter extends Tester\TestCase ['action' => 'createTerm', 'parentId' => 'c1', 'term' => '2025-1'], ['texts' => $texts] ); - }, ForbiddenRequestException::class); + }, BadRequestException::class); } public function testAddAttribute()