Skip to content

Commit 4a43895

Browse files
committed
Adding numerical suffix to group names to avoid duplicates (withing one parent group).
1 parent 498227d commit 4a43895

File tree

2 files changed

+155
-25
lines changed

2 files changed

+155
-25
lines changed

app/helpers/Recodex/RecodexApiException.php

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,39 @@
99
*/
1010
class RecodexApiException extends InternalServerException
1111
{
12+
private $response = null;
13+
private $body = null;
14+
1215
/**
1316
* Create instance with further details.
1417
* @param string $details description
1518
* @param Exception $previous Previous exception
1619
*/
17-
public function __construct(string $details, $previous = null)
20+
public function __construct(string $details, $previous = null, $response = null, $body = null)
1821
{
1922
parent::__construct(
2023
"ReCodEx API Error - $details",
2124
FrontendErrorMappings::E500_000__INTERNAL_SERVER_ERROR,
2225
null,
2326
$previous
2427
);
28+
$this->response = $response;
29+
$this->body = $body;
30+
}
31+
32+
/**
33+
* Get the response object of the failed API call, if available.
34+
*/
35+
public function getResponse()
36+
{
37+
return $this->response;
38+
}
39+
40+
/**
41+
* Get the body of the response of the failed API call, if available.
42+
*/
43+
public function getBody()
44+
{
45+
return $this->body;
2546
}
2647
}

app/helpers/Recodex/RecodexApiHelper.php

Lines changed: 133 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ class RecodexApiHelper
2020
{
2121
use Nette\SmartObject;
2222

23+
private const GROUP_DUPLICATE_NAME_ERROR_CODE = '400-504';
24+
2325
/** @var string Base of the API URL */
2426
private string $apiBase;
2527

@@ -44,6 +46,10 @@ class RecodexApiHelper
4446
/** @var GuzzleHttp\Client */
4547
private $client;
4648

49+
// caches
50+
private $groupsCache = null;
51+
private $groupsCacheUserId = null;
52+
4753
/**
4854
* @param array $config
4955
* @param GuzzleHttp\Client|null $client optional injection of HTTP client for testing purposes
@@ -143,19 +149,24 @@ private function processJsonBody($response)
143149
if ($code !== 200) {
144150
Debugger::log("HTTP request to ReCodEx API failed (response $code).", Debugger::DEBUG);
145151
Debugger::log("Response body:\n" . $response->getBody()->getContents(), Debugger::DEBUG);
146-
throw new RecodexApiException("HTTP request failed (response $code).");
152+
throw new RecodexApiException("HTTP request failed (response $code).", null, $response);
147153
}
148154

149155
$type = $response->getHeaderLine("Content-Type") ?? '';
150156
if (!str_starts_with($type, 'application/json')) {
151157
Debugger::log("JSON response expected from ReCodEx API but '$type' returned instead.", Debugger::DEBUG);
152-
throw new RecodexApiException("JSON response was expected but '$type' returned instead.");
158+
throw new RecodexApiException("JSON response was expected but '$type' returned instead.", null, $response);
153159
}
154160

155161
$body = json_decode($response->getBody()->getContents(), true);
156162
if (($body['success'] ?? false) !== true) {
157163
$code = $body['code'];
158-
throw new RecodexApiException($body['error']['message'] ?? "API responded with error code $code.");
164+
throw new RecodexApiException(
165+
$body['error']['message'] ?? "API responded with error code $code.",
166+
null,
167+
$response,
168+
$body
169+
);
159170
}
160171

161172
return $body['payload'] ?? null;
@@ -370,17 +381,24 @@ public function removeExternalId(string $id, string $service): RecodexUser
370381
*/
371382
public function getGroups(User $user): array
372383
{
373-
Debugger::log('ReCodEx::getGroups(' . $user->getId() . ')', Debugger::DEBUG);
374-
$body = $this->get(
375-
"group-attributes",
376-
['instance' => $user->getInstanceId(), 'service' => $this->extensionId, 'user' => $user->getId()]
377-
);
378-
$groups = [];
379-
foreach ($body as $groupData) {
380-
$group = new RecodexGroup($groupData, $this->extensionId);
381-
$groups[$group->id] = $group;
384+
if ($this->groupsCacheUserId !== $user->getId()) {
385+
$this->groupsCacheUserId = $user->getId();
386+
387+
Debugger::log('ReCodEx::getGroups(' . $user->getId() . ')', Debugger::DEBUG);
388+
$body = $this->get(
389+
"group-attributes",
390+
['instance' => $user->getInstanceId(), 'service' => $this->extensionId, 'user' => $user->getId()]
391+
);
392+
$groups = [];
393+
foreach ($body as $groupData) {
394+
$group = new RecodexGroup($groupData, $this->extensionId);
395+
$groups[$group->id] = $group;
396+
}
397+
398+
$this->groupsCache = $groups;
382399
}
383-
return $groups;
400+
401+
return $this->groupsCache;
384402
}
385403

386404
/**
@@ -463,6 +481,96 @@ public function removeAdminFromGroup(string $groupId, User $admin): void
463481
$this->delete("groups/$groupId/members/$adminId");
464482
}
465483

484+
/**
485+
* @param RecodexGroup[] $groups
486+
* @param array $localizedTexts
487+
* @return bool true if any of the groups has the same name in any of the locales as given in $localizedTexts
488+
*/
489+
private function isNameDuplicated(array $groups, array $localizedTexts): bool
490+
{
491+
foreach ($groups as $group) {
492+
foreach ($localizedTexts as $text) {
493+
if (($group->name[$text['locale']] ?? null) === $text['name']) {
494+
return true;
495+
}
496+
}
497+
}
498+
return false;
499+
}
500+
501+
/**
502+
* Compute suffix for group name in case of duplicate name error. The suffix is incremented until no duplication
503+
* is detected. The suffix is added in format " [num]" to the end of the name.
504+
* @param RecodexGroup[] $groups
505+
* @param array $localizedTexts
506+
* @param string $parentGroupId
507+
* @return int|null suffix to be added to the name in case of duplicate name error, null if no duplication detected
508+
*/
509+
private function getAntiDuplicateSuffix(array $groups, array $localizedTexts, string $parentGroupId): ?int
510+
{
511+
$suffix = null;
512+
$siblings = array_filter($groups, function (RecodexGroup $group) use ($parentGroupId) {
513+
return $group->parentGroupId === $parentGroupId;
514+
});
515+
516+
$originalTexts = $localizedTexts;
517+
while ($this->isNameDuplicated($siblings, $localizedTexts)) {
518+
$suffix = ($suffix ?? 1) + 1; // starting suffix is 2, then 3, etc.
519+
$localizedTexts = $originalTexts;
520+
foreach ($localizedTexts as &$text) {
521+
$text['name'] .= " [$suffix]";
522+
}
523+
}
524+
525+
return $suffix;
526+
}
527+
528+
/**
529+
* Create a new group with given parameters. If the group name is duplicated,
530+
* null is returned (can be retried with different name).
531+
* @param string $instanceId ID of the instance where the group is being created
532+
* @param string $parentGroupId ID of the parent group
533+
* @param array $localizedTexts localized texts for the group (locale => ['name' => ..., 'description' => ...])
534+
* @param int|null $localizedSuffix optional suffix [num] added to the name in case of duplicate name error
535+
* @return array|null
536+
*/
537+
private function createGroupInternal(
538+
string $instanceId,
539+
string $parentGroupId,
540+
array $localizedTexts,
541+
?int $localizedSuffix = null
542+
): ?array {
543+
if ($localizedSuffix !== null) {
544+
foreach ($localizedTexts as &$text) {
545+
$text['name'] .= " [$localizedSuffix]";
546+
}
547+
}
548+
549+
try {
550+
return $this->post("groups", [], [
551+
'instanceId' => $instanceId,
552+
'parentGroupId' => $parentGroupId,
553+
'publicStats' => false,
554+
'detaining' => true,
555+
'isPublic' => false,
556+
'isOrganizational' => false,
557+
'isExam' => false,
558+
'noAdmin' => true,
559+
'localizedTexts' => $localizedTexts,
560+
]);
561+
} catch (RecodexApiException $e) {
562+
$body = $e->getBody();
563+
if (
564+
$body && is_array($body) && ($body['code'] ?? 0) === 400 &&
565+
($body['error']['code'] ?? '') === self::GROUP_DUPLICATE_NAME_ERROR_CODE
566+
) {
567+
// special case, duplicate name error can be retried with different suffix
568+
return null;
569+
}
570+
throw $e;
571+
}
572+
}
573+
466574
/**
467575
* Create a new group and make given user an admin.
468576
* @param SisScheduleEvent $event event for which the group is being created
@@ -487,24 +595,25 @@ public function createGroup(SisScheduleEvent $event, string $parentGroupId, User
487595
}
488596
}
489597

490-
$group = $this->post("groups", [], [
491-
'instanceId' => $admin->getInstanceId(),
492-
'parentGroupId' => $parentGroupId,
493-
'publicStats' => false,
494-
'detaining' => true,
495-
'isPublic' => false,
496-
'isOrganizational' => false,
497-
'isExam' => false,
498-
'noAdmin' => true,
499-
'localizedTexts' => $localizedTexts,
500-
]);
598+
$groups = $this->getGroups($admin);
599+
$suffix = $this->getAntiDuplicateSuffix($groups, $localizedTexts, $parentGroupId);
600+
601+
$retries = 5;
602+
do {
603+
$group = $this->createGroupInternal($admin->getInstanceId(), $parentGroupId, $localizedTexts, $suffix);
604+
$suffix = ($suffix ?? 1) + 1; // starting suffix is 2, then 3, etc.
605+
} while (!$group && $retries-- > 0);
501606

502607
if ($group && !empty($group['id'])) {
503608
$this->addAdminToGroup($group['id'], $admin);
504609
$this->addAttribute($group['id'], RecodexGroup::ATTR_GROUP_KEY, $event->getSisId());
505610
return $group['id'];
506611
}
507612

613+
Debugger::log(
614+
"Failed to create group for '{$event->getSisId()}' after multiple attempts due to duplicate name.",
615+
Debugger::WARNING
616+
);
508617
return null;
509618
}
510619

0 commit comments

Comments
 (0)