Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
50 changes: 50 additions & 0 deletions app/helpers/Recodex/RecodexApiHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use GuzzleHttp;
use Nette\Utils\Arrays;
use Tracy\Debugger;
use InvalidArgumentException;

/**
* Wrapper for ReCodEx API calls.
Expand Down Expand Up @@ -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;
}
}
18 changes: 13 additions & 5 deletions app/helpers/Recodex/RecodexGroup.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
59 changes: 59 additions & 0 deletions app/presenters/GroupsPresenter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 BadRequestException("Group $groupId does not have any course attributes.");
}
if ($group->hasTermAttribute()) {
throw new BadRequestException("Group $groupId have term attributes.");
}

foreach ($groups as $group) {
if ($group->parentGroupId === $groupId && $group->hasTermAttribute($term)) {
throw new BadRequestException(
"One of the children of group $groupId already have associated term $term."
);
}
}
}

private function canUserAdministrateGroup(array $groups, string $groupId): void
{
if (empty($groups[$groupId])) {
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions app/router/RouterFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ private static function createGroupsRoutes(string $prefix): RouteList
$router[] = new PostRoute("$prefix/<id>/join", "Groups:join");
$router[] = new PostRoute("$prefix/<id>/add-attribute", "Groups:addAttribute");
$router[] = new PostRoute("$prefix/<id>/remove-attribute", "Groups:removeAttribute");
$router[] = new PostRoute("$prefix/<parentId>/create-term/<term>", "Groups:createTerm");
return $router;
}
}
2 changes: 2 additions & 0 deletions app/security/ACL/IGroupPermissions.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ public function canViewStudent(): bool;
public function canViewTeacher(): bool;

public function canEditRawAttributes(): bool;

public function canCreateTermGroup(): bool;
}
138 changes: 138 additions & 0 deletions tests/Presenters/GroupsPresenter.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -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]
);
}, BadRequestException::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]
);
}, BadRequestException::class);
}

public function testAddAttribute()
{
PresenterTestHelper::loginDefaultAdmin($this->container);
Expand Down