@@ -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