diff --git a/app/commands/Recodex/AddAdmin.php b/app/commands/Recodex/AddAdmin.php new file mode 100644 index 0000000..2864f27 --- /dev/null +++ b/app/commands/Recodex/AddAdmin.php @@ -0,0 +1,77 @@ +recodexApi = $recodexApi; + $this->users = $users; + } + + /** + * Register the command. + */ + protected function configure() + { + $this->setName(self::$defaultName)->setDescription('Add admin into a group as a member.'); + $this->addArgument('groupId', InputArgument::REQUIRED, 'ID of the group to which the admin will be added.'); + $this->addArgument('adminId', InputArgument::REQUIRED, 'ID of the admin to be added.'); + } + + /** + * @param InputInterface $input Console input, not used + * @param OutputInterface $output Console output for logging + * @return int 0 on success, 1 on error + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->input = $input; + $this->output = $output; + + $groupId = $input->getArgument('groupId'); + $adminId = $input->getArgument('adminId'); + $admin = $this->users->findOrThrow($adminId); + + $token = trim($this->prompt('Auth token: ')); + if (!$token) { + $output->writeln('No token given, terminating...'); + return Command::SUCCESS; + } + $this->recodexApi->setAuthToken($token); + + $output->writeln("Adding admin '$adminId' to group '$groupId'..."); + $this->recodexApi->addAdminToGroup($groupId, $admin); + + return Command::SUCCESS; + } +} diff --git a/app/commands/Recodex/AddAttribute.php b/app/commands/Recodex/AddAttribute.php new file mode 100644 index 0000000..a8c5714 --- /dev/null +++ b/app/commands/Recodex/AddAttribute.php @@ -0,0 +1,70 @@ +recodexApi = $recodexApi; + } + + /** + * Register the command. + */ + protected function configure() + { + $this->setName(self::$defaultName)->setDescription('Add external attribute to ReCodEx group.'); + $this->addArgument('groupId', InputArgument::REQUIRED, 'ID of the group to which the attribute will be added.'); + $this->addArgument('key', InputArgument::REQUIRED, 'The key of the attribute being added.'); + $this->addArgument('value', InputArgument::REQUIRED, 'The value of the attribute being added.'); + } + + /** + * @param InputInterface $input Console input, not used + * @param OutputInterface $output Console output for logging + * @return int 0 on success, 1 on error + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->input = $input; + $this->output = $output; + + $groupId = $input->getArgument('groupId'); + $key = $input->getArgument('key'); + $value = $input->getArgument('value'); + + $token = trim($this->prompt('Auth token: ')); + if (!$token) { + $output->writeln('No token given, terminating...'); + return Command::SUCCESS; + } + $this->recodexApi->setAuthToken($token); + + $output->writeln("Adding attribute '$key' with value '$value' to group '$groupId'..."); + $this->recodexApi->addAttribute($groupId, $key, $value); + + return Command::SUCCESS; + } +} diff --git a/app/commands/Recodex/AddStudent.php b/app/commands/Recodex/AddStudent.php new file mode 100644 index 0000000..4320de0 --- /dev/null +++ b/app/commands/Recodex/AddStudent.php @@ -0,0 +1,77 @@ +recodexApi = $recodexApi; + $this->users = $users; + } + + /** + * Register the command. + */ + protected function configure() + { + $this->setName(self::$defaultName)->setDescription('Add student into a group as a member.'); + $this->addArgument('groupId', InputArgument::REQUIRED, 'ID of the group to which the student will be added.'); + $this->addArgument('studentId', InputArgument::REQUIRED, 'ID of the student to be added.'); + } + + /** + * @param InputInterface $input Console input, not used + * @param OutputInterface $output Console output for logging + * @return int 0 on success, 1 on error + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->input = $input; + $this->output = $output; + + $groupId = $input->getArgument('groupId'); + $studentId = $input->getArgument('studentId'); + $student = $this->users->findOrThrow($studentId); + + $token = trim($this->prompt('Auth token: ')); + if (!$token) { + $output->writeln('No token given, terminating...'); + return Command::SUCCESS; + } + $this->recodexApi->setAuthToken($token); + + $output->writeln("Adding student '$studentId' to group '$groupId'..."); + $this->recodexApi->addStudentToGroup($groupId, $student); + + return Command::SUCCESS; + } +} diff --git a/app/commands/Recodex/CreateGroup.php b/app/commands/Recodex/CreateGroup.php new file mode 100644 index 0000000..d7f1a18 --- /dev/null +++ b/app/commands/Recodex/CreateGroup.php @@ -0,0 +1,96 @@ +recodexApi = $recodexApi; + $this->sisEvents = $sisEvents; + $this->users = $users; + } + + /** + * Register the command. + */ + protected function configure() + { + $this->setName(self::$defaultName)->setDescription('Create a new group in ReCodEx.'); + $this->addArgument('eventId', InputArgument::REQUIRED, 'The SIS ID of the event associated with the group.'); + $this->addArgument('parentId', InputArgument::REQUIRED, 'ReCodEx ID of the the parent group.'); + $this->addArgument('adminId', InputArgument::REQUIRED, 'ReCodEx ID of the admin of the newly created group.'); + } + + /** + * @param InputInterface $input Console input, not used + * @param OutputInterface $output Console output for logging + * @return int 0 on success, 1 on error + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->input = $input; + $this->output = $output; + + $eventId = $input->getArgument('eventId'); + $event = $this->sisEvents->findBySisId($eventId); + if (!$event) { + $output->writeln("Event with ID $eventId not found."); + return Command::FAILURE; + } + $parentId = $input->getArgument('parentId'); + $adminId = $input->getArgument('adminId'); + $admin = $this->users->get($adminId); + if (!$admin) { + $output->writeln("Admin with ID $adminId not found."); + return Command::FAILURE; + } + + $token = trim($this->prompt('Auth token: ')); + if (!$token) { + $output->writeln('No token given, terminating...'); + return Command::SUCCESS; + } + $this->recodexApi->setAuthToken($token); + + $output->writeln("Creating group for event '$eventId' under parent group '$parentId'..."); + $this->recodexApi->createGroup($event, $parentId, $admin); + + return Command::SUCCESS; + } +} diff --git a/app/commands/Recodex/Groups.php b/app/commands/Recodex/Groups.php new file mode 100644 index 0000000..ad5db33 --- /dev/null +++ b/app/commands/Recodex/Groups.php @@ -0,0 +1,149 @@ +recodexApi = $recodexApi; + $this->users = $users; + $this->sisEvents = $sisEvents; + } + + /** + * Register the command. + */ + protected function configure() + { + $this->setName(self::$defaultName)->setDescription('Load groups from ReCodEx API for given user.'); + $this->addArgument('ukco', InputArgument::REQUIRED, 'SIS ID of the user whose groups are being loaded.'); + } + + private static function printGroup(OutputInterface $output, RecodexGroup $group, int $level = 0): void + { + $admins = array_map(function ($admin) { + return $admin->lastName; + }, $group->admins); + $admins = ($admins) ? (' \<' . implode(', ', $admins) . '\>') : ''; + + $membership = ($group->membership) ? (' (' . substr($group->membership, 0, 3) . ')') : ''; + + $attributes = []; + foreach ($group->attributes as $key => $values) { + foreach ($values as $value) { + $attributes[] = "$key=$value"; + } + } + $attributes = $attributes ? (' [' . implode(', ', $attributes) . ']') : ''; + + $output->writeln(str_repeat(' ', $level) . ($group->name['en'] ?? $group->name['cs']) + . $admins . $membership . $attributes); + + foreach ($group->children as $child) { + self::printGroup($output, $child, $level + 1); + } + } + + private static function printGroups(OutputInterface $output, array $groups): void + { + $rootGroups = RecodexGroup::populateChildren($groups); + foreach ($rootGroups as $group) { + self::printGroup($output, $group); + } + } + + /** + * @param InputInterface $input Console input, not used + * @param OutputInterface $output Console output for logging + * @return int 0 on success, 1 on error + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->input = $input; + $this->output = $output; + + $ukco = $input->getArgument('ukco'); + $user = $this->users->findOneBy(['sisId' => $ukco]); + if (!$user) { + $output->writeln('User not found.'); + return Command::FAILURE; + } + + $token = trim($this->prompt('Auth token: ')); + if (!$token) { + $output->writeln('No token given, terminating...'); + return Command::SUCCESS; + } + + $this->recodexApi->setAuthToken($token); + $groups = $this->recodexApi->getGroups($user); + + // Print student's view + if ($user->getRole() === 'student' || $user->getRole() === 'supervisor-student') { + $events = $this->sisEvents->allEventsOfUser($user, null, SisAffiliation::TYPE_STUDENT); + $eventIds = array_map(fn($e) => $e->getSisId(), $events); + $studGroups = RecodexGroup::pruneForStudent($groups, $eventIds); + $output->writeln('Student view:'); + $output->writeln('-------------'); + self::printGroups($output, $studGroups); + } + + if ($user->getRole() === 'supervisor-student') { + $output->writeln("\n\n"); // this role prints both views (students and teachers) + } + + // Print teacher's view + if ($user->getRole() !== 'student') { + $events = $this->sisEvents->allEventsOfUser( + $user, + null, + [SisAffiliation::TYPE_TEACHER, SisAffiliation::TYPE_GUARANTOR] + ); + $courseIds = array_map(fn($e) => $e->getCourse()->getCode(), $events); + $teachGroups = RecodexGroup::pruneForTeacher($groups, $courseIds, []); + $output->writeln('Teacher view:'); + $output->writeln('-------------'); + self::printGroups($output, $teachGroups); + } + + return Command::SUCCESS; + } +} diff --git a/app/commands/Recodex/RemoveAdmin.php b/app/commands/Recodex/RemoveAdmin.php new file mode 100644 index 0000000..f65b974 --- /dev/null +++ b/app/commands/Recodex/RemoveAdmin.php @@ -0,0 +1,81 @@ +recodexApi = $recodexApi; + $this->users = $users; + } + + /** + * Register the command. + */ + protected function configure() + { + $this->setName(self::$defaultName)->setDescription('Remove admin from a group.'); + $this->addArgument( + 'groupId', + InputArgument::REQUIRED, + 'ID of the group from which the admin will be removed.' + ); + $this->addArgument('adminId', InputArgument::REQUIRED, 'ID of the admin to be removed.'); + } + + /** + * @param InputInterface $input Console input, not used + * @param OutputInterface $output Console output for logging + * @return int 0 on success, 1 on error + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->input = $input; + $this->output = $output; + + $groupId = $input->getArgument('groupId'); + $adminId = $input->getArgument('adminId'); + $admin = $this->users->findOrThrow($adminId); + + $token = trim($this->prompt('Auth token: ')); + if (!$token) { + $output->writeln('No token given, terminating...'); + return Command::SUCCESS; + } + $this->recodexApi->setAuthToken($token); + + $output->writeln("Removing admin '$adminId' from group '$groupId'..."); + $this->recodexApi->removeAdminFromGroup($groupId, $admin); + + return Command::SUCCESS; + } +} diff --git a/app/commands/Recodex/RemoveAttribute.php b/app/commands/Recodex/RemoveAttribute.php new file mode 100644 index 0000000..635b8ae --- /dev/null +++ b/app/commands/Recodex/RemoveAttribute.php @@ -0,0 +1,74 @@ +recodexApi = $recodexApi; + } + + /** + * Register the command. + */ + protected function configure() + { + $this->setName(self::$defaultName)->setDescription('Remove external attribute from ReCodEx group.'); + $this->addArgument( + 'groupId', + InputArgument::REQUIRED, + 'ID of the group from which the attribute will be removed.' + ); + $this->addArgument('key', InputArgument::REQUIRED, 'The key of the attribute being removed.'); + $this->addArgument('value', InputArgument::REQUIRED, 'The value of the attribute being removed.'); + } + + /** + * @param InputInterface $input Console input, not used + * @param OutputInterface $output Console output for logging + * @return int 0 on success, 1 on error + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->input = $input; + $this->output = $output; + + $groupId = $input->getArgument('groupId'); + $key = $input->getArgument('key'); + $value = $input->getArgument('value'); + + $token = trim($this->prompt('Auth token: ')); + if (!$token) { + $output->writeln('No token given, terminating...'); + return Command::SUCCESS; + } + $this->recodexApi->setAuthToken($token); + + $output->writeln("Removing attribute '$key' with value '$value' from group '$groupId'..."); + $this->recodexApi->removeAttribute($groupId, $key, $value); + + return Command::SUCCESS; + } +} diff --git a/app/commands/Recodex/RemoveStudent.php b/app/commands/Recodex/RemoveStudent.php new file mode 100644 index 0000000..788dee6 --- /dev/null +++ b/app/commands/Recodex/RemoveStudent.php @@ -0,0 +1,81 @@ +recodexApi = $recodexApi; + $this->users = $users; + } + + /** + * Register the command. + */ + protected function configure() + { + $this->setName(self::$defaultName)->setDescription('Remove student from a group.'); + $this->addArgument( + 'groupId', + InputArgument::REQUIRED, + 'ID of the group from which the student will be removed.' + ); + $this->addArgument('studentId', InputArgument::REQUIRED, 'ID of the student to be removed.'); + } + + /** + * @param InputInterface $input Console input, not used + * @param OutputInterface $output Console output for logging + * @return int 0 on success, 1 on error + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->input = $input; + $this->output = $output; + + $groupId = $input->getArgument('groupId'); + $studentId = $input->getArgument('studentId'); + $student = $this->users->findOrThrow($studentId); + + $token = trim($this->prompt('Auth token: ')); + if (!$token) { + $output->writeln('No token given, terminating...'); + return Command::SUCCESS; + } + $this->recodexApi->setAuthToken($token); + + $output->writeln("Removing student '$studentId' from group '$groupId'..."); + $this->recodexApi->removeStudentFromGroup($groupId, $student); + + return Command::SUCCESS; + } +} diff --git a/app/commands/RecodexToken.php b/app/commands/Recodex/Token.php similarity index 100% rename from app/commands/RecodexToken.php rename to app/commands/Recodex/Token.php diff --git a/app/config/config.neon b/app/config/config.neon index 66e262a..abb72c0 100644 --- a/app/config/config.neon +++ b/app/config/config.neon @@ -24,8 +24,15 @@ parameters: recodex: # just a placeholder, this needs to be overridden in local config apiBase: null extensionId: null + verifySSL: true + sisIdKey: "cas-uk" + sisLoginKey: "ldap-uk" - sis: + sis: # just a placeholder, this needs to be overridden in local config + apiBase: null + faculty: 'here-goes-faculty-id' + secretRozvrhng: "here-goes-secret-api-token-for-rozvrhng-module" + secretKdojekdo: "here-goes-secret-api-token-for-kdojekdo-module" verifySSL: true emails: # common configuration for sending email (addresses and template variables) @@ -43,7 +50,7 @@ parameters: exerciseUrl: "%webapp.address%/app/exercises/{id}" shadowAssignmentUrl: "%webapp.address%/app/shadow-assignment/{id}" solutionUrl: "%webapp.address%/app/assignment/{assignmentId}/solution/{solutionId}" - referenceSolutiontUrl: "%webapp.address%/app/exercises/{exerciseId}/reference-solution/{solutionId}" + referenceSolutionUrl: "%webapp.address%/app/exercises/{exerciseId}/reference-solution/{solutionId}" forgottenPasswordUrl: "%webapp.address%/forgotten-password/change?{token}" # URL of web application where the password can be changed emailVerificationUrl: "%webapp.address%/email-verification?{token}" invitationUrl: "%webapp.address%/accept-invitation?{token}" @@ -74,10 +81,13 @@ mail: # configuration of sending mails acl: config: %appDir%/config/permissions.neon acl: + group: App\Security\ACL\IGroupPermissions + event: App\Security\ACL\IEventPermissions term: App\Security\ACL\ITermPermissions user: App\Security\ACL\IUserPermissions policies: _: App\Security\Policies\BasePermissionPolicy + event: App\Security\Policies\EventPermissionPolicy term: App\Security\Policies\TermPermissionPolicy user: App\Security\Policies\UserPermissionPolicy @@ -103,6 +113,14 @@ services: # commands - App\Console\DoctrineFixtures + - App\Console\RecodexAddAdmin + - App\Console\RecodexAddAttribute + - App\Console\RecodexAddStudent + - App\Console\RecodexCreateGroup + - App\Console\RecodexGroups + - App\Console\RecodexRemoveAdmin + - App\Console\RecodexRemoveAttribute + - App\Console\RecodexRemoveStudent - App\Console\RecodexToken - App\Console\SisGetCourse - App\Console\SisGetUser @@ -115,6 +133,7 @@ services: - App\Helpers\EmailsConfig(%emails%) # helpers + - App\Helpers\NamingHelper - App\Helpers\RecodexApiHelper(%recodex%) - App\Helpers\SisHelper(%sis%) - App\Helpers\UserActions diff --git a/app/config/permissions.neon b/app/config/permissions.neon index 26fc7a0..5cc61bb 100644 --- a/app/config/permissions.neon +++ b/app/config/permissions.neon @@ -31,7 +31,7 @@ permissions: conditions: - user.isSameUser - + # Term permissions - allow: true resource: term actions: @@ -52,4 +52,37 @@ permissions: actions: - viewTeacherCourses conditions: - - term.isVisibleToTeachers \ No newline at end of file + - term.isVisibleToTeachers + + + # Group permissions + - allow: true + resource: group + role: student + actions: + - viewStudent + + - allow: true + resource: group + role: supervisor-student + actions: + - viewTeacher + + # Event permissions + - allow: true + resource: event + role: student + actions: + - joinGroup + conditions: + - event.isUserEnrolledFor + + - allow: true + resource: event + role: supervisor-student + actions: + - bindGroup + - createGroup + conditions: + - event.isUserTeacherOf + diff --git a/app/exceptions/NotImplementedException.php b/app/exceptions/NotImplementedException.php index b7b8dc6..39d4ab4 100644 --- a/app/exceptions/NotImplementedException.php +++ b/app/exceptions/NotImplementedException.php @@ -5,7 +5,7 @@ use Nette\Http\IResponse; /** - * Actually not used for debbuging purposes but used in production and thrown + * Actually not used for debugging purposes but used in production and thrown * if user requested non-existing application route. */ class NotImplementedException extends ApiException @@ -16,7 +16,7 @@ class NotImplementedException extends ApiException public function __construct() { parent::__construct( - "This feature is not implemented. Contact the authors of the API for more information about the status of the API.", + "This feature is not implemented (yet).", IResponse::S501_NotImplemented, FrontendErrorMappings::E501_000__NOT_IMPLEMENTED ); diff --git a/app/helpers/NamingHelper.php b/app/helpers/NamingHelper.php new file mode 100644 index 0000000..b27f88c --- /dev/null +++ b/app/helpers/NamingHelper.php @@ -0,0 +1,77 @@ +getCourse()->getCaption($locale); + $dayNames = [ + 'en' => ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], + 'cs' => ['Ne', 'Po', 'Út', 'St', 'Čt', 'Pá', 'So'], + ]; + $fortnight = [ + 'en' => [0 => 'Even weeks', 1 => 'Odd weeks'], + 'cs' => [0 => 'Sudé týdny', 1 => 'Liché týdny'], + ]; + $unscheduled = [ + 'en' => 'unscheduled', + 'cs' => 'nerozvrženo', + ]; + + if (!$courseName || empty($dayNames[$locale])) { + return null; + } + + $info = []; + if ($event->getDayOfWeek() !== null && array_key_exists($event->getDayOfWeek(), $dayNames[$locale])) { + $info[] = $dayNames[$locale][$event->getDayOfWeek()]; + } + if ($event->getTime() !== null) { + $info[] = (int)($event->getTime() / 60) . ':' + . str_pad((string)($event->getTime() % 60), 2, '0', STR_PAD_LEFT); + } + if ($event->getFortnight() && $event->getFirstWeek() !== null) { + $info[] = $fortnight[$locale][$event->getFirstWeek() % 2]; + } + if ($event->getRoom() !== null) { + $info[] = $event->getRoom(); + } + + $info = $info ? implode(', ', $info) : $unscheduled[$locale]; + return "$courseName ($info)"; + } + + /** + * Get the description of the group for a given SIS schedule event. + * @param SisScheduleEvent $event The SIS schedule event. + * @param string $locale The locale to use for translation ('cs' or 'en'). + * @return string|null The group description or null if it cannot be determined.s + */ + public function getGroupDescription(SisScheduleEvent $event, string $locale): ?string + { + $templates = [ + 'en' => 'A group create from SIS scheduling event `%s` for course `%s`.', + 'cs' => 'Skupina vytvořená z rozvrhového lístku SISu `%s` pro předmět `%s`.', + ]; + if (empty($templates[$locale])) { + return null; + } + return sprintf($templates[$locale], $event->getSisId(), $event->getCourse()->getCode()); + } +} diff --git a/app/helpers/Recodex/RecodexApiHelper.php b/app/helpers/Recodex/RecodexApiHelper.php index e3909f8..7537ea6 100644 --- a/app/helpers/Recodex/RecodexApiHelper.php +++ b/app/helpers/Recodex/RecodexApiHelper.php @@ -4,10 +4,12 @@ use App\Exceptions\ConfigException; use App\Exceptions\RecodexApiException; +use App\Model\Entity\SisScheduleEvent; use App\Model\Entity\User; use Nette; use GuzzleHttp; use Nette\Utils\Arrays; +use Tracy\Debugger; /** * Wrapper for ReCodEx API calls. @@ -34,14 +36,17 @@ class RecodexApiHelper /** @var string|null Authentication token that is added to headers */ private ?string $authToken = null; + /** @var NamingHelper */ + private NamingHelper $namingHelper; + /** @var GuzzleHttp\Client */ private $client; /** * @param array $config - * @param GuzzleHttp\HandlerStack|null $handler An optional HTTP handler (mainly for unit testing purposes) + * @param GuzzleHttp\Client|null $client optional injection of HTTP client for testing purposes */ - public function __construct(array $config, ?GuzzleHttp\HandlerStack $handler = null) + public function __construct(array $config, NamingHelper $namingHelper, ?GuzzleHttp\Client $client = null) { $this->extensionId = Arrays::get($config, "extensionId", ""); if (!$this->extensionId) { @@ -61,11 +66,12 @@ public function __construct(array $config, ?GuzzleHttp\HandlerStack $handler = n $this->sisIdKey = Arrays::get($config, "sisIdKey", "cas-uk"); $this->sisLoginKey = Arrays::get($config, "sisLoginKey", "ldap-uk"); - $options = ['base_uri' => $this->apiBase]; - if ($handler !== null) { - $options['handler'] = $handler; + if (!$client) { + $client = new GuzzleHttp\Client(['base_uri' => $this->apiBase]); } - $this->client = new GuzzleHttp\Client($options); + $this->client = $client; + + $this->namingHelper = $namingHelper; } public function getSisIdKey(): string @@ -127,15 +133,18 @@ private function processJsonBody($response) { $code = $response->getStatusCode(); if ($code !== 200) { + Debugger::log("HTTP request to ReCodEx API failed (response $code).", Debugger::DEBUG); throw new RecodexApiException("HTTP request failed (response $code)."); } $type = $response->getHeaderLine("Content-Type") ?? ''; if (!str_starts_with($type, 'application/json')) { - throw new RecodexApiException("JSON body was expected but '$type' returned instead."); + Debugger::log("JSON response expected from ReCodEx API but '$type' returned instead.", Debugger::DEBUG); + throw new RecodexApiException("JSON response was expected but '$type' returned instead."); } $body = json_decode($response->getBody()->getContents(), true); + Debugger::log($body, Debugger::DEBUG); if (($body['success'] ?? false) !== true) { $code = $body['code']; throw new RecodexApiException($body['error']['message'] ?? "API responded with error code $code."); @@ -219,6 +228,7 @@ public function getTempTokenInstance(string $token): string */ public function getTokenAndUser(): ?array { + Debugger::log('ReCodEx::getTokenAndUser()', Debugger::DEBUG); $body = $this->post('extensions/' . $this->extensionId); if (!is_array($body) || empty($body['accessToken']) || empty($body['user'])) { throw new RecodexApiException("Unexpected ReCodEx API response from extension token endpoint."); @@ -236,6 +246,7 @@ public function getTokenAndUser(): ?array */ public function getUser(string $id): ?RecodexUser { + Debugger::log('ReCodEx::getUser(' . $id . ')', Debugger::DEBUG); $body = $this->get("users/$id"); return $body ? new RecodexUser($body, $this) : null; } @@ -255,6 +266,8 @@ public function updateUser(User $user): RecodexUser 'email' => $user->getEmail(), ]; $id = $user->getId(); + Debugger::log('ReCodEx::updateUser(' . $id . ')', Debugger::INFO); + Debugger::log('New user data: ' . json_encode($body), Debugger::DEBUG); $res = $this->post("users/$id", [], $body); if (!$res || !is_array($res) || empty($res['user']) || ($res['user']['id'] ?? '') !== $id) { throw new RecodexApiException("Unexpected ReCodEx API response from update user's profile endpoint."); @@ -272,6 +285,7 @@ public function updateUser(User $user): RecodexUser */ public function setExternalId(string $id, string $service, string $externalId): RecodexUser { + Debugger::log("ReCodEx::setExternalId('$id', '$service', '$externalId')", Debugger::INFO); $res = $this->post("users/$id/external-login/$service", [], ['externalId' => $externalId]); if (!$res || !is_array($res) || ($res['id'] ?? '') !== $id) { throw new RecodexApiException("Unexpected ReCodEx API response from update user's external ID endpoint."); @@ -287,6 +301,7 @@ public function setExternalId(string $id, string $service, string $externalId): */ public function removeExternalId(string $id, string $service): RecodexUser { + Debugger::log("ReCodEx::removeExternalId('$id', '$service')", Debugger::INFO); $res = $this->delete("users/$id/external-login/$service"); if (!$res || !is_array($res) || ($res['id'] ?? '') !== $id) { throw new RecodexApiException("Unexpected ReCodEx API response from remove user's external ID endpoint."); @@ -295,12 +310,147 @@ public function removeExternalId(string $id, string $service): RecodexUser } /** - * Get all non-archived groups the user can see. + * Get all non-archived groups with attributes and membership relation to given user. + * @param User $user whose membership relation is being injected + * @return RecodexGroup[] groups indexed by their IDs */ - public function getGroups(): array + public function getGroups(User $user): array { - // TODO: this is just a placeholder, needs finishing - $groups = $this->get('groups'); + Debugger::log('ReCodEx::getGroups(' . $user->getId() . ')', Debugger::DEBUG); + $body = $this->get( + "group-attributes", + ['instance' => $user->getInstanceId(), 'service' => $this->extensionId, 'user' => $user->getId()] + ); + $groups = []; + foreach ($body as $groupData) { + $group = new RecodexGroup($groupData, $this->extensionId); + $groups[$group->id] = $group; + } return $groups; } + + /** + * Add external attribute to selected group (service ID is injected automatically). + * @param string $groupId ReCodEx ID of a group for which the attribute is being added + * @param string $key + * @param string $value + */ + public function addAttribute(string $groupId, string $key, string $value): void + { + Debugger::log("ReCodEx::addAttribute('$groupId', '$key', '$value')", Debugger::INFO); + $this->post("group-attributes/$groupId", [], [ + 'service' => $this->extensionId, + 'key' => $key, + 'value' => $value + ]); + } + + /** + * Remove external attribute from selected group (service ID is injected automatically). + * @param string $groupId ReCodEx ID of a group from which the attribute is being removed + * @param string $key + * @param string $value + */ + public function removeAttribute(string $groupId, string $key, string $value): void + { + Debugger::log("ReCodEx::removeAttribute('$groupId', '$key', '$value')", Debugger::INFO); + $this->delete("group-attributes/$groupId", [ + 'service' => $this->extensionId, + 'key' => $key, + 'value' => $value + ]); + } + + /** + * Add student to group. + * @param string $groupId ReCodEx ID of a group to which the student is being added + * @param User $student + */ + public function addStudentToGroup(string $groupId, User $student): void + { + Debugger::log("ReCodEx::addStudentToGroup('$groupId', '{$student->getId()}')", Debugger::INFO); + $studentId = $student->getId(); + $this->post("groups/$groupId/students/$studentId"); + } + + /** + * Remove student from group. + * @param string $groupId ReCodEx ID of a group from which the student is being removed + * @param User $student + */ + public function removeStudentFromGroup(string $groupId, User $student): void + { + Debugger::log("ReCodEx::removeStudentFromGroup('$groupId', '{$student->getId()}')", Debugger::INFO); + $studentId = $student->getId(); + $this->delete("groups/$groupId/students/$studentId"); + } + + /** + * Add admin to group. + * @param string $groupId ReCodEx ID of a group to which the admin is being added + * @param User $admin + */ + public function addAdminToGroup(string $groupId, User $admin): void + { + Debugger::log("ReCodEx::addAdminToGroup('$groupId', '{$admin->getId()}')", Debugger::INFO); + $adminId = $admin->getId(); + $this->post("groups/$groupId/members/$adminId", [], ['type' => 'admin']); + } + + /** + * Remove admin from group. + * @param string $groupId ReCodEx ID of a group from which the admin is being removed + * @param User $admin + */ + public function removeAdminFromGroup(string $groupId, User $admin): void + { + Debugger::log("ReCodEx::removeAdminFromGroup('$groupId', '{$admin->getId()}')", Debugger::INFO); + $adminId = $admin->getId(); + $this->delete("groups/$groupId/members/$adminId"); + } + + /** + * Create a new group and make given user an admin. + * @param SisScheduleEvent $event event for which the group is being created + * @param string $parentGroupId ID of the parent group + * @param User $admin user to be added as admin + * @return string|null ID of the created group or null on failure + */ + public function createGroup(SisScheduleEvent $event, string $parentGroupId, User $admin): ?string + { + Debugger::log("ReCodEx::createGroup('{$event->getSisId()}', '$parentGroupId')", Debugger::INFO); + + $localizedTexts = []; + foreach (['en', 'cs'] as $locale) { + $name = $this->namingHelper->getGroupName($event, $locale); + $description = $this->namingHelper->getGroupDescription($event, $locale); + if ($name !== null) { + $localizedTexts[] = [ + 'locale' => $locale, + 'name' => $name, + 'description' => $description ?? '', + ]; + } + } + + $group = $this->post("groups", [], [ + 'instanceId' => $admin->getInstanceId(), + 'parentGroupId' => $parentGroupId, + 'publicStats' => false, + 'detaining' => true, + 'isPublic' => false, + 'isOrganizational' => false, + 'isExam' => false, + 'noAdmin' => true, + 'localizedTexts' => $localizedTexts, + ]); + + if ($group && !empty($group['id'])) { + $this->addAdminToGroup($group['id'], $admin); + $this->addAttribute($group['id'], RecodexGroup::ATTR_GROUP_KEY, $event->getSisId()); + return $group['id']; + } + + return null; + } } diff --git a/app/helpers/Recodex/RecodexGroup.php b/app/helpers/Recodex/RecodexGroup.php new file mode 100644 index 0000000..54a8a94 --- /dev/null +++ b/app/helpers/Recodex/RecodexGroup.php @@ -0,0 +1,424 @@ + obj { titlesBeforeName, firstName, lastName, titlesAfterName, email } + */ + public array $admins; + + /** + * Group names indexed by locale identifiers + */ + public array $name = []; + + /** + * Group descriptions indexed by locale identifiers + */ + public array $description = []; + + /** + * Indicates whether the group is organizational (does not have assignments) + */ + public bool $organizational; + + /** + * Indicates whether the group is an exam group. + */ + public bool $exam; + + /** + * Indicates whether the group is public. + */ + public bool $public; + + /** + * Indicates whether the group is detaining (students cannot leave on their own). + */ + public bool $detaining; + + /** + * External attributes assigned by this extension. + */ + public array $attributes; + + /** + * List of child groups. + * This field is just a placeholder that needs to be populated (using static function populateChildren). + */ + public array $children = []; + + /** + * Indicates the membership type of the logged in user to the group. + * Possible values are: 'admin', 'supervisor', 'observer', and 'student' + * (and null if there is no relation between the user and the group). + */ + public ?string $membership = null; + + /** + * Validates that admins array contains proper associative arrays with required keys + * @param array $admins + * @throws RecodexApiException if admins structure is invalid + */ + private function processAdminsStructure(array $admins): array + { + $requiredAdminKeys = ['titlesBeforeName', 'firstName', 'lastName', 'titlesAfterName', 'email']; + + foreach ($admins as $adminId => $adminData) { + if (!is_array($adminData)) { + throw new RecodexApiException( + "Admin with ID '$adminId' must be an associative array, " . gettype($adminData) . ' given' + ); + } + + foreach ($requiredAdminKeys as $key) { + if (!array_key_exists($key, $adminData)) { + throw new RecodexApiException( + "Admin with ID '$adminId' is missing required key '$key'" + ); + } + } + + $admins[$adminId] = (object)$adminData; + } + + return $admins; + } + + /** + * Validates localizedTexts structure and transforms it into name/description arrays + * @param array $localizedTexts + * @return array Returns associative array with 'name' and 'description' keys containing locale-indexed arrays + * @throws RecodexApiException if localizedTexts structure is invalid + */ + private function processLocalizedTexts(array $localizedTexts): array + { + $requiredLocalizedTextKeys = ['locale', 'name', 'description']; + $name = []; + $description = []; + + foreach ($localizedTexts as $index => $localizedTextData) { + if (!is_array($localizedTextData)) { + throw new RecodexApiException( + "LocalizedText at index '$index' must be an associative array, " . + gettype($localizedTextData) . ' given' + ); + } + + foreach ($requiredLocalizedTextKeys as $key) { + if (!array_key_exists($key, $localizedTextData)) { + throw new RecodexApiException( + "LocalizedText at index '$index' is missing required key '$key'" + ); + } + } + + // Transform into locale-indexed arrays + $locale = $localizedTextData['locale']; + $name[$locale] = $localizedTextData['name']; + $description[$locale] = $localizedTextData['description']; + } + + return ['name' => $name, 'description' => $description]; + } + + /** + * @param array $data parsed JSON group view + * @param string $attributesService name of the attributes service (this application's namespace) + */ + public function __construct(array $data, string $attributesService) + { + // Validate all required keys are present in API response + $requiredKeys = [ + 'id', + 'parentGroupId', + 'admins', + 'localizedTexts', + 'organizational', + 'exam', + 'public', + 'detaining', + 'attributes', + 'membership' + ]; + + foreach ($requiredKeys as $key) { + if (!array_key_exists($key, $data)) { + throw new RecodexApiException("Missing required key '$key' in group API response"); + } + } + + // Process and validate localizedTexts array structure + $localizedData = $this->processLocalizedTexts($data['localizedTexts']); + + // Initialize public members from the associative array (values can be null) + $this->id = $data['id']; + $this->parentGroupId = $data['parentGroupId']; + $this->admins = $this->processAdminsStructure($data['admins']); + $this->name = $localizedData['name']; + $this->description = $localizedData['description']; + $this->organizational = $data['organizational']; + $this->exam = $data['exam']; + $this->public = $data['public']; + $this->detaining = $data['detaining']; + $this->attributes = $data['attributes'][$attributesService] ?? []; + $this->membership = $data['membership']; + } + + /** + * Serializes the object to a value that can be serialized natively by json_encode(). + * @return array Data which can be serialized by json_encode + */ + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'parentGroupId' => $this->parentGroupId, + 'admins' => $this->admins, + 'name' => $this->name, + 'description' => $this->description, + 'organizational' => $this->organizational, + 'exam' => $this->exam, + 'public' => $this->public, + 'detaining' => $this->detaining, + 'attributes' => $this->attributes, + 'membership' => $this->membership, + ]; + } + + /* + * Public helper methods (data getters) + */ + public function hasAttribute(string $key, string $value): bool + { + return in_array($value, $this->attributes[$key] ?? [], true); + } + + public function hasGroupAttribute($groupId) + { + return $this->hasAttribute(self::ATTR_GROUP_KEY, $groupId); + } + + public function hasTermAttribute($term) + { + return $this->hasAttribute(self::ATTR_TERM_KEY, $term); + } + + public function hasCourseAttribute($courseId) + { + return $this->hasAttribute(self::ATTR_COURSE_KEY, $courseId); + } + + /* + * Private static methods + */ + + /** + * Make sure all ancestor groups are included in the selection. The selected groups array is updated in place. + * @param RecodexGroup[] $selectedGroups groups selected so far (id => RecodexGroup) + * @param RecodexGroup[] $allGroups all available groups (id => RecodexGroup) + */ + private static function ancestralClosure(array &$selectedGroups, array $allGroups): void + { + $toCheck = array_keys($selectedGroups); + while ($toCheck) { + $currentId = array_shift($toCheck); + if (empty($allGroups[$currentId])) { + continue; + } + + $parentId = $allGroups[$currentId]->parentGroupId; + if ($parentId && empty($selectedGroups[$parentId])) { + $selectedGroups[$parentId] = $allGroups[$parentId]; + $toCheck[] = $parentId; + } + } + } + + /** + * Checks if the group belongs to any SIS group. + * @param RecodexGroup $group The group to check. + * @param array $sisGroupsIndex The index of SIS groups [ sisGroupId => unused value ]. + * @return bool True if the group belongs to any SIS group, false otherwise. + */ + private static function belongsToSisGroups(RecodexGroup $group, array $sisGroupsIndex): bool + { + foreach ($group->attributes[self::ATTR_GROUP_KEY] ?? [] as $sisGrpId) { + if (array_key_exists($sisGrpId, $sisGroupsIndex)) { + return true; + } + } + return false; + } + + private static function belongsToCourses(RecodexGroup $group, array $coursesIndex): bool + { + foreach ($group->attributes[self::ATTR_COURSE_KEY] ?? [] as $courseId) { + if (array_key_exists($courseId, $coursesIndex)) { + return true; + } + } + return false; + } + + /* + * Public static methods -- array operations for groups + */ + + /** + * Sorts the groups by their name (by English name, Czech name is used as fallback). + * @param RecodexGroup[] $groups The list of groups to sort (in place) + */ + public static function sortGroupsByName(array &$groups): void + { + usort($groups, fn($a, $b) => strcmp($a->name['en'] ?? $a->name['cs'], $b->name['en'] ?? $b->name['cs'])); + } + + /** + * Populates the children array for each group based on the parentGroupId. + * @param RecodexGroup[] $groups The list of groups to populate children for. + * @return RecodexGroup[] The list of root groups (those without a parent). + */ + public static function populateChildren(array $groups): array + { + $rootGroups = []; + foreach ($groups as $group) { + if ($group->parentGroupId) { + if (!isset($groups[$group->parentGroupId])) { + throw new LogicException('Parent group not found'); + } + $groups[$group->parentGroupId]->children[] = $group; + } else { + $rootGroups[] = $group; + } + } + + foreach ($groups as $group) { + self::sortGroupsByName($group->children); + } + self::sortGroupsByName($rootGroups); + + return $rootGroups; + } + + /** + * Prunes the group list for students, keeping only relevant groups. + * Relevant are groups that belong to any SIS group or where the student already belongs to + * (the ancestral closure of relevant groups is returned so hierarchical names can be displayed). + * @param RecodexGroup[] $groups The list of groups to prune (indexed by group IDs). + * @param string[] $sisGroups The list of SIS group IDs. + * @return RecodexGroup[] The pruned list of groups (indexed by group IDs). + */ + public static function pruneForStudent(array $groups, array $sisGroups): array + { + $sisGroupsIndex = array_flip($sisGroups); + $pruned = []; + foreach ($groups as $id => $group) { + if (self::belongsToSisGroups($group, $sisGroupsIndex) || $group->membership === 'student') { + $pruned[$id] = $group; + } + } + + self::ancestralClosure($pruned, $groups); + return $pruned; + } + + /** + * Prunes the group list for teachers, keeping only relevant groups. + * Relevant groups are those that belong to any of the specified courses, + * plus all their descendants (possible targets) and ancestors (for hierarchical naming). + * @param RecodexGroup[] $groups The list of groups to prune (indexed by group IDs). + * @param string[] $courses The list of course IDs. + * @param string[] $sisGroups The list of SIS group IDs. + * @return RecodexGroup[] The pruned list of groups (indexed by group IDs). + */ + public static function pruneForTeacher(array $groups, array $courses, array $sisGroups): array + { + $coursesIndex = array_flip($courses); + $sisGroupsIndex = array_flip($sisGroups); + $pruned = []; + $boundGroups = []; + foreach ($groups as $id => $group) { + if (self::belongsToCourses($group, $coursesIndex)) { + $pruned[$id] = $group; + } + if (self::belongsToSisGroups($group, $sisGroupsIndex)) { + $boundGroups[$id] = $group; + } + } + + // iteratively scan the groups, add children of pruned groups as long as the pruned array grows + // Note: the tree structure is very flat, this takes 3 or 4 iterations at the most + do { + $changed = false; + foreach ($groups as $id => $group) { + if ( + $group->parentGroupId && !array_key_exists($id, $pruned) + && array_key_exists($group->parentGroupId, $pruned) + ) { + // group is not in the result, but its parent is => we must add it as well + $pruned[$id] = $group; + $changed = true; // another run will be required + } + } + } while ($changed); + + // inject directly bound groups before ancestral closure + // this is a rare case, since bound groups should be normally already among course descendant groups + foreach ($boundGroups as $id => $group) { + if (!array_key_exists($id, $pruned)) { + $pruned[$id] = $group; + } + } + + self::ancestralClosure($pruned, $groups); + return $pruned; + } +} diff --git a/app/helpers/Recodex/RecodexUser.php b/app/helpers/Recodex/RecodexUser.php index 40bfc9b..97ec11f 100644 --- a/app/helpers/Recodex/RecodexUser.php +++ b/app/helpers/Recodex/RecodexUser.php @@ -7,7 +7,7 @@ use Nette; /** - * Wraper for User data sent from ReCodEx API. + * Wrapper for User data sent from ReCodEx API. */ class RecodexUser { diff --git a/app/helpers/SisHelper/SisHelper.php b/app/helpers/SisHelper/SisHelper.php index 2eb2743..2302e75 100644 --- a/app/helpers/SisHelper/SisHelper.php +++ b/app/helpers/SisHelper/SisHelper.php @@ -110,7 +110,7 @@ public function getUserRecord(string $sisUserId): SisUserRecord 'token' => $salt . '$' . hash('sha256', "$salt,$this->secretKdojekdo"), ]; - Debugger::log("getUserRecord($sisUserId)", Debugger::DEBUG); + Debugger::log("SIS::getUserRecord($sisUserId)", Debugger::DEBUG); try { $response = $this->client->get('kdojekdo/rest.php', $this->prepareOptions($params)); } catch (GuzzleHttp\Exception\ClientException $e) { @@ -165,7 +165,7 @@ public function getCourses(string $sisUserId, mixed $terms = null) $termsStr = 'null'; } - Debugger::log("getCourses($sisUserId, $termsStr)", Debugger::DEBUG); + Debugger::log("SIS::getCourses($sisUserId, $termsStr)", Debugger::DEBUG); try { $response = $this->client->get('rozvrhng/rest.php', $this->prepareOptions($params)); } catch (GuzzleHttp\Exception\ClientException $e) { diff --git a/app/helpers/WebappLinks.php b/app/helpers/WebappLinks.php index f5048fe..5ed5e6a 100644 --- a/app/helpers/WebappLinks.php +++ b/app/helpers/WebappLinks.php @@ -27,7 +27,7 @@ class WebappLinks private $solutionUrl; /** @var string */ - private $referenceSolutiontUrl; + private $referenceSolutionUrl; /** @var string */ private $forgottenPasswordUrl; @@ -69,9 +69,9 @@ public function __construct(string $webappUrl, array $linkTemplates) ["solutionUrl"], "$webappUrl/app/assignment/{assignmentId}/solution/{solutionId}" ); - $this->referenceSolutiontUrl = Arrays::get( + $this->referenceSolutionUrl = Arrays::get( $linkTemplates, - ["referenceSolutiontUrl"], + ["referenceSolutionUrl"], "$webappUrl/app/exercises/{exerciseId}/reference-solution/{solutionId}" ); $this->forgottenPasswordUrl = Arrays::get( @@ -141,7 +141,7 @@ public function getSolutionPageUrl(string $assignmentId, string $solutionId): st public function getReferenceSolutionPageUrl(string $exerciseId, string $solutionId): string { return self::getLink( - $this->referenceSolutiontUrl, + $this->referenceSolutionUrl, ['exerciseId' => $exerciseId, 'solutionId' => $solutionId] ); } diff --git a/app/model/entity/SisScheduleEvent.php b/app/model/entity/SisScheduleEvent.php index f2df8eb..9122ae8 100644 --- a/app/model/entity/SisScheduleEvent.php +++ b/app/model/entity/SisScheduleEvent.php @@ -54,31 +54,31 @@ class SisScheduleEvent implements JsonSerializable protected $type; /** - * @ORM\Column(type="integer") + * @ORM\Column(type="integer", nullable=true) * Day of the week (0=Sunday, 1=Monday...6=Saturday) */ protected $dayOfWeek; /** - * @ORM\Column(type="integer") + * @ORM\Column(type="integer", nullable=true) * When the lecture starts (logical weeks of the semester). */ protected $firstWeek; /** - * @ORM\Column(type="integer") + * @ORM\Column(type="integer", nullable=true) * Time of the day when the event starts as minutes from midnight. */ protected $time; /** - * @ORM\Column(type="integer") + * @ORM\Column(type="integer", nullable=true) * Length of the event in minutes. */ protected $length; /** - * @ORM\Column(type="string") + * @ORM\Column(type="string", nullable=true) * Where the event is located. */ protected $room; @@ -143,32 +143,32 @@ public function setType(string $type): void $this->type = $type; } - public function getDayOfWeek(): int + public function getDayOfWeek(): ?int { return $this->dayOfWeek; } - public function getFirstWeek(): int + public function getFirstWeek(): ?int { return $this->firstWeek; } - public function getTime(): int + public function getTime(): ?int { return $this->time; } - public function getLength(): int + public function getLength(): ?int { return $this->length; } - public function setLength(int $length): void + public function setLength(?int $length): void { $this->length = $length; } - public function getRoom(): string + public function getRoom(): ?string { return $this->room; } @@ -179,11 +179,11 @@ public function getFortnight(): bool } public function setSchedule( - int $dayOfWeek, - int $firstWeek, - int $time, - int $length, - string $room, + ?int $dayOfWeek, + ?int $firstWeek, + ?int $time, + ?int $length, + ?string $room, bool $fortnight = false ): void { $this->dayOfWeek = $dayOfWeek; diff --git a/app/model/entity/SisTerm.php b/app/model/entity/SisTerm.php index cc31c2c..14f2ef5 100644 --- a/app/model/entity/SisTerm.php +++ b/app/model/entity/SisTerm.php @@ -132,6 +132,11 @@ public function getTerm(): int return $this->term; } + public function getYearTermKey(): string + { + return sprintf("%d-%d", $this->year, $this->term); + } + public function getBeginning(): ?DateTime { return $this->beginning; diff --git a/app/model/repository/SisScheduleEvents.php b/app/model/repository/SisScheduleEvents.php index b24fc9b..0fda748 100644 --- a/app/model/repository/SisScheduleEvents.php +++ b/app/model/repository/SisScheduleEvents.php @@ -26,10 +26,10 @@ public function findBySisId(string $sisId): ?SisScheduleEvent * Get all scheduling events for a specific user (optionally filter by term and affiliation). * @param User $user * @param SisTerm|null $term if given, only events of particular term are returned - * @param string|null $affiliation if given, only events with particular affiliation are returned + * @param string|string[]|null $affiliation if given, only events with particular affiliation(s) are returned * @return SisScheduleEvent[] */ - public function allEventsOfUser(User $user, ?SisTerm $term = null, ?string $affiliation = null): array + public function allEventsOfUser(User $user, ?SisTerm $term = null, mixed $affiliation = null): array { $qb = $this->createQueryBuilder('e') ->innerJoin('e.affiliations', 'a') @@ -42,8 +42,13 @@ public function allEventsOfUser(User $user, ?SisTerm $term = null, ?string $affi } if ($affiliation) { - $qb->andWhere('a.type = :affiliation') - ->setParameter('affiliation', $affiliation); + if (is_string($affiliation)) { + $qb->andWhere('a.type = :affiliation') + ->setParameter('affiliation', $affiliation); + } elseif (is_array($affiliation)) { + $qb->andWhere('a.type IN (:affiliation)') + ->setParameter('affiliation', $affiliation); + } } return $qb->getQuery()->getResult(); diff --git a/app/presenters/CoursesPresenter.php b/app/presenters/CoursesPresenter.php index c995202..23e8032 100644 --- a/app/presenters/CoursesPresenter.php +++ b/app/presenters/CoursesPresenter.php @@ -131,7 +131,7 @@ private function refetchSisCourses(User $user): void // find active terms and create mapping termId => SisTerm $terms = []; foreach ($this->sisTerms->findAllActive() as $term) { - $key = sprintf("%s-%s", $term->getYear(), $term->getTerm()); + $key = $term->getYearTermKey(); $terms[$key] = $term; // we need to clear current affiliations to reflect when students' get unenrolled from courses diff --git a/app/presenters/GroupsPresenter.php b/app/presenters/GroupsPresenter.php index 6ff513a..26ac3a3 100644 --- a/app/presenters/GroupsPresenter.php +++ b/app/presenters/GroupsPresenter.php @@ -2,37 +2,344 @@ namespace App\Presenters; -use App\Exceptions\ForbiddenRequestException; use App\Exceptions\BadRequestException; -use App\Model\Entity\SisTerm; -use App\Model\Repository\SisTerms; -use App\Security\ACL\ITermPermissions; -use Nette\Application\Request; -use DateTime; +use App\Exceptions\ForbiddenRequestException; +use App\Exceptions\NotFoundException; +use App\Helpers\RecodexGroup; +use App\Model\Entity\SisScheduleEvent; +use App\Model\Repository\SisScheduleEvents; +use App\Security\ACL\IEventPermissions; +use App\Security\ACL\IGroupPermissions; /** * Group management (both for teachers and students). */ -class GroupsPresenter extends BasePresenter +class GroupsPresenter extends BasePresenterWithApi { /** - * @var SisTerms + * @var SisScheduleEvents * @inject */ - public $sisTerms; + public $sisEvents; + + /** + * @var IEventPermissions + * @inject + */ + public $eventAcl; + + /** + * @var IGroupPermissions + * @inject + */ + public $groupAcl; + + private function isGroupSuitableForEvent(array $groups, string $groupId, SisScheduleEvent $event): void + { + if (empty($groups[$groupId])) { + throw new NotFoundException("Group $groupId does not exist or is not accessible by the user."); + } + + $courseId = $event->getCourse()->getCode(); + $term = $event->getTerm()->getYearTermKey(); + $courseCheck = $termCheck = false; + $group = $groups[$groupId]; + while ($group !== null && (!$courseCheck || !$termCheck)) { + $courseCheck = $courseCheck || $group->hasCourseAttribute($courseId); + $termCheck = $termCheck || $group->hasTermAttribute($term); + $group = $group->parentGroupId ? ($groups[$group->parentGroupId] ?? null) : null; + } + if (!$courseCheck || !$termCheck) { + throw new ForbiddenRequestException("Group $groupId is not located under the required course or term."); + } + } + + private function canUserAdministrateGroup(array $groups, string $groupId): 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->membership === RecodexGroup::MEMBERSHIP_SUPERVISOR) { + return; // direct supervisor has sufficient rights + } + + // admin of selected group or any ancestor group also has sufficient rights + while ($group !== null) { + if ($group->membership === RecodexGroup::MEMBERSHIP_ADMIN) { + return; + } + $group = $group->parentGroupId ? ($groups[$group->parentGroupId] ?? null) : null; + } + + throw new ForbiddenRequestException("You do not have permissions to administrate group $groupId."); + } public function checkDefault() { - // throw new ForbiddenRequestException("You do not have permissions to list terms."); + if (!$this->groupAcl->canViewAll()) { + throw new ForbiddenRequestException("You do not have permissions to list groups."); + } } /** + * Proxy to ReCodEx that retrieves all groups accessible by the user. * @GET */ public function actionDefault() { - $terms = $this->sisTerms->findAll(); - $this->sendSuccessResponse($terms); + $groups = $this->recodexApi->getGroups($this->getCurrentUser()); + $this->sendSuccessResponse($groups); + } + + public function checkStudent() + { + if (!$this->groupAcl->canViewStudent()) { + throw new ForbiddenRequestException("You do not have permissions to list student groups."); + } + } + + /** + * Proxy to ReCodEx that retrieves all groups relevant for student (joining groups). + * @GET + * @Param(type="query", name="eventIds", validation="array", + * description="List of SIS group IDs that we search for.") + */ + public function actionStudent(array $eventIds) + { + $groups = $this->recodexApi->getGroups($this->getCurrentUser()); + $groups = RecodexGroup::pruneForStudent($groups, $eventIds); + $this->sendSuccessResponse($groups); + } + + public function checkTeacher() + { + if (!$this->groupAcl->canViewTeacher()) { + throw new ForbiddenRequestException("You do not have permissions to list teacher groups."); + } + } + + /** + * Proxy to ReCodEx that retrieves all groups relevant for teacher creating groups. + * @GET + * @Param(type="query", name="eventIds", validation="array", + * description="List of SIS group IDs the teacher teaches.") + * @Param(type="query", name="courseIds", validation="array", + * description="List of SIS courses the teacher is involved in.") + */ + public function actionTeacher(array $eventIds, array $courseIds) + { + $groups = $this->recodexApi->getGroups($this->getCurrentUser()); + $groups = RecodexGroup::pruneForTeacher($groups, $courseIds, $eventIds); + $this->sendSuccessResponse($groups); + } + + public function checkCreate(string $parentId, string $eventId) + { + $event = $this->sisEvents->findOrThrow($eventId); + if (!$this->eventAcl->canCreateGroup($event)) { + throw new ForbiddenRequestException("You do not have permissions to create groups for selected SIS event."); + } + + $groups = $this->recodexApi->getGroups($this->getCurrentUser()); + $this->isGroupSuitableForEvent($groups, $parentId, $event); // throws exception if not suitable + + // We are not checking ReCodEx permissions since the T.A.s may have none. + // This is the reason we are creating the groups via this extension (to bypass/extend regular permissions). + } + + /** + * Proxy to ReCodEx that creates a new group. + * @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="eventId", validation="string:1..", + * description="Internal ID of the scheduling event the new group is created for") + */ + public function actionCreate(string $parentId, string $eventId) + { + $event = $this->sisEvents->findOrThrow($eventId); + $this->recodexApi->createGroup($event, $parentId, $this->getCurrentUser()); + $this->sendSuccessResponse("OK"); + } + + public function checkBind(string $id, string $eventId) + { + $event = $this->sisEvents->findOrThrow($eventId); + if (!$this->eventAcl->canBindGroup($event)) { + throw new ForbiddenRequestException("You do not have permissions to bind groups for selected SIS event."); + } + + $groups = $this->recodexApi->getGroups($this->getCurrentUser()); + $this->canUserAdministrateGroup($groups, $id); // throws exception if not + $this->isGroupSuitableForEvent($groups, $id, $event); // throws exception if not + if ($groups[$id]->organizational) { + throw new BadRequestException("Group $id is organizational, so it cannot be bound to a SIS event."); + } + + if ($groups[$id]->hasGroupAttribute($event->getSisId())) { + throw new BadRequestException("Group $id is already bound to the selected SIS event."); + } + } + + /** + * Proxy to ReCodEx that binds a group with schedule event (student group) in SIS. + * This basically sets the 'group' attribute to ReCodEx group entity. + * @POST + * @Param(type="query", name="id", validation="string:1..", + * description="ReCodEx ID of a group that will be bound with the event.") + * @Param(type="query", name="eventId", validation="string:1..", + * description="Internal ID of the scheduling event that will be bound with the group.") + */ + public function actionBind(string $id, string $eventId) + { + $event = $this->sisEvents->findOrThrow($eventId); + $this->recodexApi->addAttribute($id, RecodexGroup::ATTR_GROUP_KEY, $event->getSisId()); + $this->sendSuccessResponse("OK"); + } + + public function checkUnbind(string $id, string $eventId) + { + $event = $this->sisEvents->findOrThrow($eventId); + if (!$this->eventAcl->canBindGroup($event)) { + throw new ForbiddenRequestException("You do not have permissions to unbind groups for selected SIS event."); + } + + $groups = $this->recodexApi->getGroups($this->getCurrentUser()); + $this->canUserAdministrateGroup($groups, $id); // throws exception if not + + if (!$groups[$id]->hasGroupAttribute($event->getSisId())) { + throw new BadRequestException("Group $id is not bound to the selected SIS event."); + } + } + + /** + * Proxy to ReCodEx that unbinds a group with schedule event (student group) in SIS. + * This basically removes the 'group' attribute from ReCodEx group entity. + * @POST + * @Param(type="query", name="id", validation="string:1..", + * description="ReCodEx ID of a group from which the event will be unbound.") + * @Param(type="query", name="eventId", validation="string:1..", + * description="Internal ID of the scheduling event that will be unbound from the group.") + */ + public function actionUnbind(string $id, string $eventId) + { + $event = $this->sisEvents->findOrThrow($eventId); + $this->recodexApi->removeAttribute($id, RecodexGroup::ATTR_GROUP_KEY, $event->getSisId()); + $this->sendSuccessResponse("OK"); + } + + public function checkJoin(string $id) + { + $groups = $this->recodexApi->getGroups($this->getCurrentUser()); + if (empty($groups[$id])) { + throw new NotFoundException("Group $id does not exist or is not accessible by the user."); + } + + $group = $groups[$id]; + if ($group->membership !== null) { + throw new BadRequestException("User is already a member ($group->membership) of group $id."); + } + + foreach ($group->attributes[RecodexGroup::ATTR_GROUP_KEY] ?? [] as $eventId) { + $event = $this->sisEvents->findBySisId($eventId); + if ($event && $this->eventAcl->canJoinGroup($event)) { + return; // suitable event was found + } + } + + // no corresponding event found -> deny access + throw new ForbiddenRequestException( + "Group $id does not correspond to any of SIS events you are enrolled for." + ); + } + + /** + * Proxy to ReCodEx that adds current user to selected group. + * @POST + * @Param(type="query", name="id", validation="string:1..", + * description="ReCodEx ID of a group the user wish to join.") + */ + public function actionJoin(string $id) + { + $user = $this->getCurrentUser(); + $this->recodexApi->addStudentToGroup($id, $user); + $this->sendSuccessResponse("OK"); + } + + public function checkAddAttribute() + { + if (!$this->groupAcl->canEditRawAttributes()) { + throw new ForbiddenRequestException("You do not have permissions to edit raw group attributes."); + } + } + + /** + * Proxy to ReCodEx that adds an attribute to a group. + * This is rather low-level operation for super-admins only (to edit top-level and term groups). + * @POST + * @Param(type="query", name="id", validation="string:1..", + * description="ReCodEx ID of a group to which the attribute will be added.") + * @Param(type="post", name="key", validation="string:1..", + * description="Key of the attribute to add.") + * @Param(type="post", name="value", validation="string:1..", + * description="Value of the attribute to add.") + */ + public function actionAddAttribute(string $id) + { + $key = $this->getRequest()->getPost('key'); + $value = $this->getRequest()->getPost('value'); + + $groups = $this->recodexApi->getGroups($this->getCurrentUser()); + $group = $groups[$id] ?? null; + if ($group === null) { + throw new BadRequestException("Group $id does not exist or is not accessible by the user."); + } + + if ($group->hasAttribute($key, $value)) { + throw new BadRequestException("Group $id already has attribute $key=$value."); + } + + $this->recodexApi->addAttribute($id, $key, $value); + $this->sendSuccessResponse("OK"); + } + + public function checkRemoveAttribute() + { + if (!$this->groupAcl->canEditRawAttributes()) { + throw new ForbiddenRequestException("You do not have permissions to edit raw group attributes."); + } + } + + /** + * Proxy to ReCodEx that removes an attribute from a group. + * This is rather low-level operation for super-admins only (to edit top-level and term groups). + * @POST + * @Param(type="query", name="id", validation="string:1..", + * description="ReCodex ID of a group from which the attribute will be removed.") + * @Param(type="post", name="key", validation="string:1..", + * description="Key of the attribute to remove.") + * @Param(type="post", name="value", validation="string:1..", + * description="Value of the attribute to remove.") + */ + public function actionRemoveAttribute(string $id) + { + $key = $this->getRequest()->getPost('key'); + $value = $this->getRequest()->getPost('value'); + + $groups = $this->recodexApi->getGroups($this->getCurrentUser()); + $group = $groups[$id] ?? null; + if ($group === null) { + throw new BadRequestException("Group $id does not exist or is not accessible by the user."); + } + + if (!$group->hasAttribute($key, $value)) { + throw new BadRequestException("Group $id does not have attribute $key=$value."); + } + + $this->recodexApi->removeAttribute($id, $key, $value); + $this->sendSuccessResponse("OK"); } } diff --git a/app/presenters/base/BasePresenterWithApi.php b/app/presenters/base/BasePresenterWithApi.php index 7a33eb5..bf78dbb 100644 --- a/app/presenters/base/BasePresenterWithApi.php +++ b/app/presenters/base/BasePresenterWithApi.php @@ -25,15 +25,17 @@ class BasePresenterWithApi extends BasePresenter public function startup() { - parent::startup(); - // Initialize ReCodEx auth token (main part is in User entity, suffix is in our auth token's payload). $user = $this->getCurrentUser(); $token = $this->getAccessToken(); - $suffix = $token->getPayload('suffix'); + $suffix = $token->getPayloadOrDefault('suffix', null); if ($user->getRecodexToken() && $suffix) { $this->recodexApi->setAuthToken($user->getRecodexToken() . $suffix); } + + // the parent startup performs authorization checks, so it must be called after we set the token + // (some presenters check permissions via ReCodEx API) + parent::startup(); } } diff --git a/app/router/RouterFactory.php b/app/router/RouterFactory.php index 49437fa..c4a5e94 100644 --- a/app/router/RouterFactory.php +++ b/app/router/RouterFactory.php @@ -74,10 +74,14 @@ private static function createGroupsRoutes(string $prefix): RouteList { $router = new RouteList(); $router[] = new GetRoute("$prefix", "Groups:default"); - $router[] = new PostRoute("$prefix", "Groups:create"); + $router[] = new GetRoute("$prefix/student", "Groups:student"); + $router[] = new GetRoute("$prefix/teacher", "Groups:teacher"); + $router[] = new PostRoute("$prefix//create/", "Groups:create"); $router[] = new PostRoute("$prefix//bind/", "Groups:bind"); $router[] = new DeleteRoute("$prefix//bind/", "Groups:unbind"); $router[] = new PostRoute("$prefix//join", "Groups:join"); + $router[] = new PostRoute("$prefix//add-attribute", "Groups:addAttribute"); + $router[] = new PostRoute("$prefix//remove-attribute", "Groups:removeAttribute"); return $router; } } diff --git a/app/security/ACL/IEventPermissions.php b/app/security/ACL/IEventPermissions.php new file mode 100644 index 0000000..1754e70 --- /dev/null +++ b/app/security/ACL/IEventPermissions.php @@ -0,0 +1,14 @@ +affiliations = $affiliations; + } + + public function getAssociatedClass() + { + return SisScheduleEvent::class; + } + + public function isUserEnrolledFor(Identity $identity, SisScheduleEvent $event): bool + { + $currentUser = $identity->getUserData(); + if (!$currentUser) { + return false; + } + + $affiliation = $this->affiliations->getAffiliation($event, $currentUser); + return $affiliation !== null && $affiliation->getType() === SisAffiliation::TYPE_STUDENT; + } + + public function isUserTeacherOf(Identity $identity, SisScheduleEvent $event): bool + { + $currentUser = $identity->getUserData(); + if (!$currentUser) { + return false; + } + + $affiliation = $this->affiliations->getAffiliation($event, $currentUser); + return $affiliation !== null && $affiliation->getType() !== SisAffiliation::TYPE_STUDENT; + // (affiliation exists and it's anything but student) + } +} diff --git a/app/security/TokenScope.php b/app/security/TokenScope.php index 52d8d3d..6a8b274 100644 --- a/app/security/TokenScope.php +++ b/app/security/TokenScope.php @@ -43,9 +43,9 @@ class TokenScope public const EMAIL_VERIFICATION = "email-verification"; /** - * Scope used for 3rd party tools designed to externally manage groups and student memeberships. + * Scope used for 3rd party tools designed to externally manage groups and student memberships. */ - public const GROUP_EXTERNAL_ATTRIBUTES = "group-external-attributes"; + public const GROUP_EXTERNAL = "group-external"; /** * Scope for managing the users. Used in case the user data needs to be updated from an external database. diff --git a/migrations/Version20250903173423.php b/migrations/Version20250903173423.php new file mode 100644 index 0000000..a2282af --- /dev/null +++ b/migrations/Version20250903173423.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE sis_schedule_event CHANGE day_of_week day_of_week INT DEFAULT NULL, CHANGE first_week first_week INT DEFAULT NULL, CHANGE time time INT DEFAULT NULL, CHANGE length length INT DEFAULT NULL, CHANGE room room VARCHAR(255) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE sis_schedule_event CHANGE day_of_week day_of_week INT NOT NULL, CHANGE first_week first_week INT NOT NULL, CHANGE time time INT NOT NULL, CHANGE length length INT NOT NULL, CHANGE room room VARCHAR(255) NOT NULL'); + } +} diff --git a/tests/Presenters/GroupsPresenter.phpt b/tests/Presenters/GroupsPresenter.phpt new file mode 100644 index 0000000..ed52436 --- /dev/null +++ b/tests/Presenters/GroupsPresenter.phpt @@ -0,0 +1,982 @@ +container = $container; + $this->em = PresenterTestHelper::getEntityManager($container); + $this->user = $container->getByType(\Nette\Security\User::class); + $this->users = $container->getByType(Users::class); + $this->client = Mockery::mock(Client::class); + + $recodexHelperName = current($this->container->findByType(RecodexApiHelper::class)); + $this->namingHelper = $this->container->getByType(NamingHelper::class); + $this->container->removeService($recodexHelperName); + $this->container->addService($recodexHelperName, new RecodexApiHelper( + [ + 'extensionId' => 'sis-cuni', + 'apiBase' => 'https://recodex.example/', + ], + $this->namingHelper, + $this->client + )); + + $sisHelperName = current($this->container->findByType(SisHelper::class)); + $this->container->removeService($sisHelperName); + $this->container->addService($sisHelperName, Mockery::mock(SisHelper::class)); + } + + protected function setUp() + { + PresenterTestHelper::fillDatabase($this->container); + $this->presenter = PresenterTestHelper::createPresenter( + $this->container, + GroupsPresenter::class + ); + } + + protected function tearDown() + { + Mockery::close(); + + if ($this->user->isLoggedIn()) { + $this->user->logout(true); + } + } + + private static function group( + string $id, + ?string $parentId, + string $name, + bool $org = false, + array $attributes = [], + ?string $membership = null + ): array { + return [ + "id" => $id, + "parentGroupId" => $parentId, + "admins" => [ + "teacher1" => [ + "titlesBeforeName" => "", + "firstName" => "First", + "lastName" => "Teacher", + "titlesAfterName" => "", + "email" => "teacher1@recodex" + ] + ], + "localizedTexts" => [ + [ + "id" => "text1", + "locale" => "en", + "name" => $name, + "description" => "", + "createdAt" => 1738275050 + ] + ], + "organizational" => $org, + "exam" => false, + "public" => false, + "detaining" => false, + "attributes" => $attributes ? ['sis-cuni' => $attributes] : [], + "membership" => $membership + ]; + } + + public function testListGroupsAll() + { + PresenterTestHelper::loginDefaultAdmin($this->container); + $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('p1', null, 'Parent', true, []), + self::group('p2', null, 'Parent 2', true, []), + self::group('g1', 'p1', 'Group 1', false, ['group' => ['sis1']]), + self::group('g2', 'p1', 'Group 2', false, ['group' => ['sis2']], 'student'), + self::group('g3', 'p1', 'Group 3', false, ['group' => ['sis3']]), + ] + ]))); + + $payload = PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'Groups', + 'GET', + ['action' => 'default'] + ); + + Assert::count(5, $payload); + + $ids = array_map(fn($group) => $group->id, $payload); + sort($ids); + Assert::equal(['g1', 'g2', 'g3', 'p1', 'p2'], $ids); + Assert::null($payload['p1']->parentGroupId); + Assert::null($payload['p2']->parentGroupId); + Assert::equal('p1', $payload['g1']->parentGroupId); + Assert::equal('p1', $payload['g2']->parentGroupId); + Assert::equal('p1', $payload['g3']->parentGroupId); + Assert::true($payload['p1']->organizational); + Assert::true($payload['p2']->organizational); + Assert::false($payload['g1']->organizational); + Assert::false($payload['g2']->organizational); + Assert::false($payload['g3']->organizational); + } + + public function testListGroupsStudent() + { + PresenterTestHelper::login($this->container, PresenterTestHelper::STUDENT1_LOGIN); + $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('p1', null, 'Parent', true, []), + self::group('p2', null, 'Parent 2', true, []), + self::group('g1', 'p1', 'Group 1', false, ['group' => ['sis1']]), + self::group('g2', 'p1', 'Group 2', false, ['group' => ['sis2']], 'student'), + self::group('g3', 'p1', 'Group 3', false, ['group' => ['sis3']]), + ] + ]))); + + $payload = PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'Groups', + 'GET', + ['action' => 'student', 'eventIds' => ['sis1']] + ); + + Assert::count(3, $payload); + + $ids = array_map(fn($group) => $group->id, $payload); + sort($ids); + Assert::equal(['g1', 'g2', 'p1'], $ids); + Assert::null($payload['p1']->parentGroupId); + Assert::equal('p1', $payload['g1']->parentGroupId); + Assert::equal('p1', $payload['g2']->parentGroupId); + Assert::true($payload['p1']->organizational); + Assert::false($payload['g1']->organizational); + Assert::false($payload['g2']->organizational); + } + + public function testListGroupsTeacher() + { + PresenterTestHelper::login($this->container, PresenterTestHelper::TEACHER1_LOGIN); + $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('p1', 'r', 'Prg 1', true, ['course' => ['prg1']]), + self::group('p2', 'r', 'Prg 2', true, ['course' => ['prg2']], 'teacher'), + self::group('p3', 'r', 'Prg 1 & 3', true, ['course' => ['prg3', 'prg1']]), + self::group('p4', 'r', 'Prg 2 & 4', true, ['course' => ['prg2', 'prg4']]), + self::group('g1', 'p1', 'Group 1'), + self::group('g2', 'p2', 'Group 2', false, ['group' => ['sis2']], 'teacher'), + self::group('g3', 'p3', 'Group 3'), + self::group('g4', 'p4', 'Group 4'), + ] + ]))); + + $payload = PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'Groups', + 'GET', + ['action' => 'teacher', 'eventIds' => ['sis2'], 'courseIds' => ['prg1']] + ); + + Assert::count(7, $payload); + + $ids = array_map(fn($group) => $group->id, $payload); + sort($ids); + Assert::equal(['g1', 'g2', 'g3', 'p1', 'p2', 'p3', 'r'], $ids); + } + + public function testBindGroup() + { + PresenterTestHelper::login($this->container, PresenterTestHelper::TEACHER1_LOGIN); + $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); + Assert::notNull($event); + + $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()]], 'admin'), + self::group('t1', 'c1', 'Term group', true, [RecodexGroup::ATTR_TERM_KEY => [$event->getTerm()->getYearTermKey()]]), + self::group('g1', 't1', 'Group 1', false, []), + ] + ]))); + + $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' => 'bind', 'id' => 'g1', 'eventId' => $event->getId()] + ); + + Assert::equal('OK', $payload); + } + + public function testBindTermGroup() + { + PresenterTestHelper::login($this->container, PresenterTestHelper::TEACHER1_LOGIN); + $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); + Assert::notNull($event); + + $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', false, [RecodexGroup::ATTR_TERM_KEY => [$event->getTerm()->getYearTermKey()]], 'supervisor'), + ] + ]))); + + $this->client->shouldReceive("post")->with('group-attributes/t1', 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' => 'bind', 'id' => 't1', 'eventId' => $event->getId()] + ); + + Assert::equal('OK', $payload); + } + + public function testBindGroupFailWrong1() + { + PresenterTestHelper::login($this->container, PresenterTestHelper::STUDENT1_LOGIN); + $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); + Assert::notNull($event); + + Assert::exception(function () use ($event) { + PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'Groups', + 'POST', + ['action' => 'bind', 'id' => 'g1', 'eventId' => $event->getId()] + ); + }, ForbiddenRequestException::class); + } + + public function testBindGroupFailWrong2() + { + PresenterTestHelper::login($this->container, PresenterTestHelper::TEACHER1_LOGIN); + $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); + Assert::notNull($event); + + $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, []), + self::group('g1', 't1', 'Group 1', false, []), + ] + ]))); + + Assert::exception(function () use ($event) { + PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'Groups', + 'POST', + ['action' => 'bind', 'id' => 'g1', 'eventId' => $event->getId()] + ); + }, ForbiddenRequestException::class); + } + + public function testBindGroupFailWrong3() + { + PresenterTestHelper::login($this->container, PresenterTestHelper::TEACHER1_LOGIN); + $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); + Assert::notNull($event); + + $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, []), + self::group('t1', 'c1', 'Term group', true, [RecodexGroup::ATTR_TERM_KEY => [$event->getTerm()->getYearTermKey()]]), + self::group('g1', 't1', 'Group 1', false, []), + ] + ]))); + + Assert::exception(function () use ($event) { + PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'Groups', + 'POST', + ['action' => 'bind', 'id' => 'g1', 'eventId' => $event->getId()] + ); + }, ForbiddenRequestException::class); + } + + public function testBindGroupFailUnauthorized() + { + PresenterTestHelper::login($this->container, PresenterTestHelper::TEACHER1_LOGIN); + $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); + Assert::notNull($event); + + $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()]], 'supervisor'), + self::group('t1', 'c1', 'Term group', true, [RecodexGroup::ATTR_TERM_KEY => [$event->getTerm()->getYearTermKey()]]), + self::group('g1', 't1', 'Group 1', false, []), + ] + ]))); + + Assert::exception(function () use ($event) { + PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'Groups', + 'POST', + ['action' => 'bind', 'id' => 'g1', 'eventId' => $event->getId()] + ); + }, ForbiddenRequestException::class); + } + + public function testBindGroupFailAlreadyBound() + { + PresenterTestHelper::login($this->container, PresenterTestHelper::TEACHER1_LOGIN); + $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); + Assert::notNull($event); + + $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()]], 'admin'), + self::group('t1', 'c1', 'Term group', true, [RecodexGroup::ATTR_TERM_KEY => [$event->getTerm()->getYearTermKey()]]), + self::group('g1', 't1', 'Group 1', false, [RecodexGroup::ATTR_GROUP_KEY => [$event->getSisId()]]), + ] + ]))); + + Assert::exception(function () use ($event) { + PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'Groups', + 'POST', + ['action' => 'bind', 'id' => 'g1', 'eventId' => $event->getId()] + ); + }, BadRequestException::class); + } + + public function testBindGroupFailOrganizational() + { + PresenterTestHelper::login($this->container, PresenterTestHelper::TEACHER1_LOGIN); + $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); + Assert::notNull($event); + + $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()]], 'supervisor'), + ] + ]))); + + Assert::exception(function () use ($event) { + PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'Groups', + 'POST', + ['action' => 'bind', 'id' => 't1', 'eventId' => $event->getId()] + ); + }, BadRequestException::class); + } + + public function testUnbindGroupAdmin() + { + PresenterTestHelper::login($this->container, PresenterTestHelper::TEACHER1_LOGIN); + $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); + Assert::notNull($event); + + $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, [], 'admin'), + self::group('g1', 'c1', 'Group 1', false, [RecodexGroup::ATTR_GROUP_KEY => [$event->getSisId()]]), + ] + ]))); + + $this->client->shouldReceive("delete")->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' => 'unbind', 'id' => 'g1', 'eventId' => $event->getId()] + ); + + Assert::equal('OK', $payload); + } + + public function testUnbindGroupSupervisor() + { + PresenterTestHelper::login($this->container, PresenterTestHelper::TEACHER1_LOGIN); + $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); + Assert::notNull($event); + + $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('g1', 'r', 'Group 1', false, [RecodexGroup::ATTR_GROUP_KEY => [$event->getSisId()]], 'supervisor'), + ] + ]))); + + $this->client->shouldReceive("delete")->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' => 'unbind', 'id' => 'g1', 'eventId' => $event->getId()] + ); + + Assert::equal('OK', $payload); + } + + public function testUnbindGroupFailUnauthorized() + { + PresenterTestHelper::login($this->container, PresenterTestHelper::STUDENT1_LOGIN); + $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); + Assert::notNull($event); + + Assert::exception(function () use ($event) { + PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'Groups', + 'POST', + ['action' => 'unbind', 'id' => 'g1', 'eventId' => $event->getId()] + ); + }, ForbiddenRequestException::class); + } + + public function testUnbindGroupFailUnauthorized2() + { + PresenterTestHelper::login($this->container, PresenterTestHelper::TEACHER1_LOGIN); + $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); + Assert::notNull($event); + + $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('g1', 'r', 'Group 1', false, [RecodexGroup::ATTR_GROUP_KEY => [$event->getSisId()]], 'student'), + ] + ]))); + + Assert::exception(function () use ($event) { + PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'Groups', + 'POST', + ['action' => 'unbind', 'id' => 'g1', 'eventId' => $event->getId()] + ); + }, ForbiddenRequestException::class); + } + + public function testUnbindGroupFailNotBound() + { + PresenterTestHelper::login($this->container, PresenterTestHelper::TEACHER1_LOGIN); + $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); + Assert::notNull($event); + + $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('g1', 'r', 'Group 1', false, [], 'admin'), + ] + ]))); + + Assert::exception(function () use ($event) { + PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'Groups', + 'POST', + ['action' => 'unbind', 'id' => 'g1', 'eventId' => $event->getId()] + ); + }, BadRequestException::class); + } + + public function testJoinGroup() + { + PresenterTestHelper::login($this->container, PresenterTestHelper::STUDENT1_LOGIN); + $studentId = $this->users->findOneBy(['email' => PresenterTestHelper::STUDENT1_LOGIN])?->getId(); + $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); + Assert::notNull($event); + + $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('g1', 'r', 'Group 1', false, [RecodexGroup::ATTR_GROUP_KEY => [$event->getSisId()]], null), + ] + ]))); + + $this->client->shouldReceive("post")->with("groups/g1/students/$studentId", 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' => 'join', 'id' => 'g1'] + ); + + Assert::equal('OK', $payload); + } + + public function testJoinGroupFailNoEvent() + { + PresenterTestHelper::login($this->container, PresenterTestHelper::STUDENT1_LOGIN); + + $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('g1', 'r', 'Group 1', false, [], null), + ] + ]))); + + Assert::exception(function () { + PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'Groups', + 'POST', + ['action' => 'join', 'id' => 'g1'] + ); + }, ForbiddenRequestException::class); + } + + public function testJoinGroupFailAlreadyMember() + { + PresenterTestHelper::login($this->container, PresenterTestHelper::STUDENT1_LOGIN); + $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); + Assert::notNull($event); + + $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('g1', 'r', 'Group 1', false, [RecodexGroup::ATTR_GROUP_KEY => [$event->getSisId()]], 'student'), + ] + ]))); + + Assert::exception(function () { + PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'Groups', + 'POST', + ['action' => 'join', 'id' => 'g1'] + ); + }, BadRequestException::class); + } + + public function testCreateGroup() + { + PresenterTestHelper::login($this->container, PresenterTestHelper::TEACHER1_LOGIN); + $user = $this->users->findOneBy(['email' => PresenterTestHelper::TEACHER1_LOGIN]); + Assert::notNull($user); + $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); + Assert::notNull($event); + + $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()]]), + ] + ]))); + + $this->client->shouldReceive("post")->with('groups', Mockery::on(function ($arg) use ($user, $event) { + Assert::type('array', $arg); + Assert::type('array', $arg['json'] ?? null); + $body = $arg['json']; + Assert::equal($user->getInstanceId(), $body['instanceId']); + Assert::equal('t1', $body['parentGroupId']); + Assert::false($body['publicStats']); + Assert::true($body['detaining']); + Assert::false($body['isPublic']); + Assert::false($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($this->namingHelper->getGroupName($event, $locale), $localizedText['name'] ?? null); + Assert::equal($this->namingHelper->getGroupDescription($event, $locale), $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('groups/g1/members/' . $user->getId(), Mockery::any()) + ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ + 'success' => true, + 'code' => 200, + 'payload' => "OK" + ]))); + + $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' => 'create', 'parentId' => 't1', 'eventId' => $event->getId()] + ); + + Assert::equal("OK", $payload); + } + + public function testCreateGroupFailWrongParent() + { + PresenterTestHelper::login($this->container, PresenterTestHelper::TEACHER1_LOGIN); + $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); + Assert::notNull($event); + + $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, []), + self::group('t1', 'c1', 'Term group', true, [RecodexGroup::ATTR_TERM_KEY => [$event->getTerm()->getYearTermKey()]]), + ] + ]))); + + + Assert::exception(function () use ($event) { + PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'Groups', + 'POST', + ['action' => 'create', 'parentId' => 't1', 'eventId' => $event->getId()] + ); + }, ForbiddenRequestException::class); + } + + public function testCreateGroupFailWrongParent2() + { + PresenterTestHelper::login($this->container, PresenterTestHelper::TEACHER1_LOGIN); + $event = $this->presenter->sisEvents->findOneBy(['sisId' => 'gl1p']); + Assert::notNull($event); + + $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, []), + ] + ]))); + + + Assert::exception(function () use ($event) { + PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'Groups', + 'POST', + ['action' => 'create', 'parentId' => 't1', 'eventId' => $event->getId()] + ); + }, ForbiddenRequestException::class); + } + + public function testAddAttribute() + { + PresenterTestHelper::loginDefaultAdmin($this->container); + + $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('g1', null, 'Root', true, ['foo' => ['bar']]), + ] + ]))); + + $this->client->shouldReceive("post")->with('group-attributes/g1', Mockery::on(function ($arg) { + Assert::type('array', $arg); + Assert::type('array', $arg['json'] ?? null); + $body = $arg['json']; + Assert::equal('sis-cuni', $body['service'] ?? null); + Assert::equal('foo', $body['key'] ?? null); + Assert::equal('baz', $body['value'] ?? null); + return true; + })) + ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ + 'success' => true, + 'code' => 200, + 'payload' => "OK" + ]))); + + $payload = PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'Groups', + 'POST', + ['action' => 'addAttribute', 'id' => 'g1'], + ['key' => 'foo', 'value' => 'baz'] + ); + + Assert::equal("OK", $payload); + } + + public function testAddAttributeWrongGroup() + { + PresenterTestHelper::loginDefaultAdmin($this->container); + + $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('g1', null, 'Root', true, ['foo' => ['bar']]), + ] + ]))); + + Assert::exception(function () { + PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'Groups', + 'POST', + ['action' => 'addAttribute', 'id' => 'g2'], + ['key' => 'foo', 'value' => 'baz'] + ); + }, BadRequestException::class); + } + + public function testAddAttributeAlreadyExists() + { + PresenterTestHelper::loginDefaultAdmin($this->container); + + $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('g1', null, 'Root', true, ['foo' => ['bar']]), + ] + ]))); + + Assert::exception(function () { + PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'Groups', + 'POST', + ['action' => 'addAttribute', 'id' => 'g1'], + ['key' => 'foo', 'value' => 'bar'] + ); + }, BadRequestException::class); + } + + public function testRemoveAttribute() + { + PresenterTestHelper::loginDefaultAdmin($this->container); + + $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('g1', null, 'Root', true, ['foo' => ['bar', 'baz']]), + ] + ]))); + + $this->client->shouldReceive("delete")->with('group-attributes/g1', Mockery::on(function ($arg) { + Assert::type('array', $arg); + Assert::type('array', $arg['query'] ?? null); + $query = $arg['query']; + Assert::equal('sis-cuni', $query['service'] ?? null); + Assert::equal('foo', $query['key'] ?? null); + Assert::equal('bar', $query['value'] ?? null); + return true; + })) + ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([ + 'success' => true, + 'code' => 200, + 'payload' => "OK" + ]))); + + $payload = PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'Groups', + 'POST', + ['action' => 'removeAttribute', 'id' => 'g1'], + ['key' => 'foo', 'value' => 'bar'] + ); + + Assert::equal("OK", $payload); + } + + public function testRemoveAttributeWrongGroup() + { + PresenterTestHelper::loginDefaultAdmin($this->container); + + $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('g1', null, 'Root', true, ['foo' => ['bar']]), + ] + ]))); + + Assert::exception(function () { + PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'Groups', + 'POST', + ['action' => 'removeAttribute', 'id' => 'g2'], + ['key' => 'foo', 'value' => 'bar'] + ); + }, BadRequestException::class); + } + + public function testRemoveAttributeDoesNotExist() + { + PresenterTestHelper::loginDefaultAdmin($this->container); + + $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('g1', null, 'Root', true, ['foo' => ['bar']]), + ] + ]))); + + Assert::exception(function () { + PresenterTestHelper::performPresenterRequest( + $this->presenter, + 'Groups', + 'POST', + ['action' => 'removeAttribute', 'id' => 'g1'], + ['key' => 'foo', 'value' => 'baz'] + ); + }, BadRequestException::class); + } +} + +Debugger::$logDirectory = __DIR__ . '/../../log'; +(new TestGroupsPresenter())->run(); diff --git a/tests/Presenters/TermsPresenter.phpt b/tests/Presenters/TermsPresenter.phpt index 0f2095f..fc94e64 100644 --- a/tests/Presenters/TermsPresenter.phpt +++ b/tests/Presenters/TermsPresenter.phpt @@ -64,7 +64,7 @@ class TestTermsPresenter extends Tester\TestCase ); $terms = array_map(function ($term) { - return $term->getYear() . '-' . $term->getTerm(); + return $term->getYearTermKey(); }, $payload); sort($terms); Assert::equal(['2024-1', '2024-2', '2025-1', '2025-2'], $terms); @@ -335,7 +335,7 @@ class TestTermsPresenter extends Tester\TestCase Assert::equal('OK', $payload); $terms = array_map(function ($term) { - return $term->getYear() . '-' . $term->getTerm(); + return $term->getYearTermKey(); }, $this->presenter->sisTerms->findAll()); sort($terms); Assert::equal(['2024-2', '2025-1', '2025-2'], $terms);