Skip to content

Commit 552ce29

Browse files
committed
Auth: Add command to synchronize user groups from Azure - refs BT#22639
1 parent f35579c commit 552ce29

File tree

7 files changed

+257
-20
lines changed

7 files changed

+257
-20
lines changed

public/main/inc/lib/usergroup.lib.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1317,6 +1317,8 @@ public function subscribe_users_to_usergroup(
13171317
}
13181318

13191319
/**
1320+
* @deprecated Use UsergroupRepository::getByTitleInUrl().
1321+
*
13201322
* @param string $title
13211323
*
13221324
* @return bool
@@ -1555,8 +1557,8 @@ public function save($params, $showQuery = false)
15551557
$params['updated_at'] = $params['created_at'] = api_get_utc_datetime();
15561558
$params['group_type'] = isset($params['group_type']) ? Usergroup::SOCIAL_CLASS : Usergroup::NORMAL_CLASS;
15571559
$params['allow_members_leave_group'] = isset($params['allow_members_leave_group']) ? 1 : 0;
1558-
$params['url'] = isset($params['url']) ? $params['url'] : "";
1559-
$params['visibility'] = isset($params['visibility']) ? $params['visibility'] : Usergroup::GROUP_PERMISSION_OPEN;
1560+
$params['url'] = $params['url'] ?? "";
1561+
$params['visibility'] = $params['visibility'] ?? Usergroup::GROUP_PERMISSION_OPEN;
15601562

15611563
$userGroupExists = $this->usergroup_exists(trim($params['title']));
15621564
if (false === $userGroupExists) {

src/CoreBundle/Command/AzureSyncAbstractCommand.php

Lines changed: 77 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,14 @@
77
namespace Chamilo\CoreBundle\Command;
88

99
use Chamilo\CoreBundle\Entity\AzureSyncState;
10+
use Chamilo\CoreBundle\Helpers\AccessUrlHelper;
1011
use Chamilo\CoreBundle\Helpers\AuthenticationConfigHelper;
1112
use Chamilo\CoreBundle\Helpers\AzureAuthenticatorHelper;
13+
use Chamilo\CoreBundle\Helpers\UserHelper;
1214
use Chamilo\CoreBundle\Repository\AzureSyncStateRepository;
15+
use Chamilo\CoreBundle\Repository\Node\UsergroupRepository;
1316
use Chamilo\CoreBundle\Repository\Node\UserRepository;
17+
use Chamilo\CoreBundle\Settings\SettingsManager;
1418
use Doctrine\ORM\EntityManagerInterface;
1519
use Exception;
1620
use Generator;
@@ -39,6 +43,10 @@ public function __construct(
3943
readonly protected AzureSyncStateRepository $syncStateRepo,
4044
protected readonly EntityManagerInterface $entityManager,
4145
protected readonly UserRepository $userRepository,
46+
protected readonly UsergroupRepository $usergroupRepository,
47+
protected readonly AccessUrlHelper $accessUrlHelper,
48+
protected readonly SettingsManager $settingsManager,
49+
protected readonly UserHelper $userHelper,
4250
) {
4351
parent::__construct();
4452

@@ -74,12 +82,12 @@ protected function getAzureUsers(): Generator
7482

7583
$query = $usersDeltaLink
7684
? $usersDeltaLink->getValue()
77-
: \sprintf('$select=%s', implode(',', AzureAuthenticatorHelper::QUERY_FIELDS));
85+
: \sprintf('$select=%s', implode(',', AzureAuthenticatorHelper::QUERY_USER_FIELDS));
7886
} else {
7987
$query = \sprintf(
8088
'$top=%d&$select=%s',
8189
AzureSyncState::API_PAGE_SIZE,
82-
implode(',', AzureAuthenticatorHelper::QUERY_FIELDS)
90+
implode(',', AzureAuthenticatorHelper::QUERY_USER_FIELDS)
8391
);
8492
}
8593

@@ -128,16 +136,10 @@ protected function getAzureUsers(): Generator
128136
*/
129137
protected function getAzureGroupMembers(string $groupUid): Generator
130138
{
131-
$userFields = [
132-
'mail',
133-
'mailNickname',
134-
'id',
135-
];
136-
137139
$query = \sprintf(
138140
'$top=%d&$select=%s',
139141
AzureSyncState::API_PAGE_SIZE,
140-
implode(',', $userFields)
142+
implode(',', AzureAuthenticatorHelper::QUERY_GROUP_MEMBERS_FIELDS)
141143
);
142144

143145
$token = null;
@@ -147,14 +149,14 @@ protected function getAzureGroupMembers(string $groupUid): Generator
147149
$this->generateOrRefreshToken($token);
148150

149151
$azureGroupMembersRequest = $this->provider->get(
150-
"groups/$groupUid/members?$query",
152+
"/v1.0/groups/$groupUid/members?$query",
151153
$token
152154
);
153155
} catch (GuzzleException|Exception $e) {
154156
throw new Exception('Exception when requesting group members from Azure: '.$e->getMessage());
155157
}
156158

157-
$azureGroupMembers = $azureGroupMembersRequest['value'] ?? [];
159+
$azureGroupMembers = $azureGroupMembersRequest ?? [];
158160

159161
foreach ($azureGroupMembers as $azureGroupMember) {
160162
yield $azureGroupMember;
@@ -166,6 +168,69 @@ protected function getAzureGroupMembers(string $groupUid): Generator
166168
$hasNextLink = true;
167169
$query = parse_url($azureGroupMembersRequest['@odata.nextLink'], PHP_URL_QUERY);
168170
}
169-
} while ($hasNextLink = false);
171+
} while ($hasNextLink);
172+
}
173+
174+
/**
175+
* @throws Exception
176+
*/
177+
protected function getAzureGroups(): Generator
178+
{
179+
$getUsergroupsDelta = 'true' === $this->providerParams['script_usergroups_delta'];
180+
181+
if ($getUsergroupsDelta) {
182+
$usergroupsDeltaLink = $this->syncStateRepo->findOneBy(['title' => AzureSyncState::USERGROUPS_DATALINK]);
183+
184+
$query = $usergroupsDeltaLink
185+
? $usergroupsDeltaLink->getValue()
186+
: sprintf('$select=%s', implode(',', AzureAuthenticatorHelper::QUERY_GROUP_FIELDS));
187+
} else {
188+
$query = sprintf(
189+
'$top=%d&$select=%s',
190+
AzureSyncState::API_PAGE_SIZE,
191+
implode(',', AzureAuthenticatorHelper::QUERY_GROUP_FIELDS)
192+
);
193+
}
194+
195+
$token = null;
196+
197+
do {
198+
try {
199+
$this->generateOrRefreshToken($token);
200+
201+
$azureGroupsRequest = $this->provider->get(
202+
$getUsergroupsDelta ? "/v1.0/groups/delta?$query" : "/v1.0/groups?$query",
203+
$token
204+
);
205+
} catch (Exception|GuzzleException $e) {
206+
throw new Exception('Exception when requesting groups from Azure: '.$e->getMessage());
207+
}
208+
209+
$azureGroupsInfo = $azureGroupsRequest ?? [];
210+
211+
foreach ($azureGroupsInfo as $azureGroupInfo) {
212+
if (!empty($this->providerParams['group_filter_regex']) &&
213+
!preg_match("/{$this->providerParams['group_filter_regex']}/", $azureGroupInfo['displayName'])
214+
) {
215+
continue;
216+
}
217+
218+
yield $azureGroupInfo;
219+
}
220+
221+
$hasNextLink = false;
222+
223+
if (!empty($azureGroupsRequest['@odata.nextLink'])) {
224+
$hasNextLink = true;
225+
$query = parse_url($azureGroupsRequest['@odata.nextLink'], PHP_URL_QUERY);
226+
}
227+
228+
if ($getUsergroupsDelta && !empty($azureGroupsRequest['@odata.deltaLink'])) {
229+
$this->syncStateRepo->save(
230+
AzureSyncState::USERGROUPS_DATALINK,
231+
parse_url($azureGroupsRequest['@odata.deltaLink'], PHP_URL_QUERY),
232+
);
233+
}
234+
} while ($hasNextLink);
170235
}
171236
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
<?php
2+
3+
/* For licensing terms, see /license.txt */
4+
5+
declare(strict_types=1);
6+
7+
namespace Chamilo\CoreBundle\Command;
8+
9+
use Chamilo\CoreBundle\Entity\Usergroup;
10+
use Exception;
11+
use Symfony\Component\Console\Attribute\AsCommand;
12+
use Symfony\Component\Console\Command\Command;
13+
use Symfony\Component\Console\Input\InputInterface;
14+
use Symfony\Component\Console\Output\OutputInterface;
15+
use Symfony\Component\Console\Style\SymfonyStyle;
16+
17+
#[AsCommand(
18+
name: 'app:azure-sync-usergroups',
19+
description: 'Synchronize groups registered in Azure with Chamilo user groups',
20+
)]
21+
class AzureSyncUsergroupsCommand extends AzureSyncAbstractCommand
22+
{
23+
protected function execute(InputInterface $input, OutputInterface $output): int
24+
{
25+
$io = new SymfonyStyle($input, $output);
26+
$io->title('Synchronizing groups from Azure.');
27+
28+
$accessUrl = $this->accessUrlHelper->getCurrent();
29+
30+
/** @var array<string, Usergroup> $groupIdByUid */
31+
$groupIdByUid = [];
32+
33+
$admin = $this->userRepository->getRootUser();
34+
35+
try {
36+
foreach ($this->getAzureGroups() as $azureGroupInfo) {
37+
$userGroup = $this->usergroupRepository->getOneByTitleInUrl($azureGroupInfo['displayName'], $accessUrl);
38+
39+
if ($userGroup) {
40+
$userGroup->getUsers()->clear();
41+
42+
$io->text(
43+
sprintf(
44+
'Class exists, all users unsubscribed: %s (ID %d)',
45+
$userGroup->getTitle(),
46+
$userGroup->getId()
47+
)
48+
);
49+
} else {
50+
$userGroup = (new Usergroup())
51+
->setTitle($azureGroupInfo['displayName'])
52+
->setDescription($azureGroupInfo['description'])
53+
->setCreator($admin)
54+
;
55+
56+
if ('true' === $this->settingsManager->getSetting('profile.allow_teachers_to_classes')) {
57+
$userGroup->setAuthorId(
58+
$this->userHelper->getCurrent()->getId()
59+
);
60+
}
61+
62+
$userGroup->addAccessUrl($accessUrl);
63+
64+
$this->usergroupRepository->create($userGroup);
65+
66+
$io->text(sprintf('Class created: %s (ID %d)', $userGroup->getTitle(), $userGroup->getId()));
67+
}
68+
69+
$groupIdByUid[$azureGroupInfo['id']] = $userGroup;
70+
}
71+
} catch (Exception $e) {
72+
$io->error($e->getMessage());
73+
74+
return Command::INVALID;
75+
}
76+
77+
$io->section('Subscribing users to groups');
78+
79+
foreach ($groupIdByUid as $azureGroupUid => $group) {
80+
$newGroupMembers = [];
81+
82+
$io->text(sprintf('Obtaining members for group (ID %d)', $group->getId()));
83+
84+
try {
85+
foreach ($this->getAzureGroupMembers($azureGroupUid) as $azureGroupMember) {
86+
if ($userId = $this->azureHelper->getUserIdByVerificationOrder($azureGroupMember)) {
87+
$newGroupMembers[] = $userId;
88+
}
89+
}
90+
} catch (Exception $e) {
91+
$io->warning($e->getMessage());
92+
93+
continue;
94+
}
95+
96+
foreach ($newGroupMembers as $newGroupMemberId) {
97+
$user = $this->userRepository->find($newGroupMemberId);
98+
99+
$group->addUser($user);
100+
}
101+
102+
$io->text(
103+
sprintf(
104+
'User IDs subscribed in class (ID %d): %s',
105+
$group->getId(),
106+
implode(', ', $newGroupMembers)
107+
)
108+
);
109+
}
110+
111+
$this->entityManager->flush();
112+
113+
$io->success('Done.');
114+
115+
return Command::SUCCESS;
116+
}
117+
}

src/CoreBundle/Entity/Usergroup.php

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ class Usergroup extends AbstractResource implements ResourceInterface, ResourceI
139139
/**
140140
* @var Collection<int, UsergroupRelUser>
141141
*/
142-
#[ORM\OneToMany(mappedBy: 'usergroup', targetEntity: UsergroupRelUser::class, cascade: ['persist'])]
142+
#[ORM\OneToMany(mappedBy: 'usergroup', targetEntity: UsergroupRelUser::class, cascade: ['persist'], orphanRemoval: true)]
143143
protected Collection $users;
144144

145145
/**
@@ -226,10 +226,20 @@ public function setUsers(Collection $users): void
226226
$this->addUsers($user);
227227
}
228228
}
229-
public function addUsers(UsergroupRelUser $user): self
229+
public function addUsers(UsergroupRelUser $relUser): self
230230
{
231-
$user->setUsergroup($this);
232-
$this->users[] = $user;
231+
$relUser->setUsergroup($this);
232+
$this->users[] = $relUser;
233+
234+
$user = $relUser->getUser();
235+
236+
foreach ($this->courses as $relcourse) {
237+
$relcourse->getCourse()->addUserAsStudent($user);
238+
}
239+
240+
foreach ($this->sessions as $relSession) {
241+
$relSession->getSession()->addUserInSession(Session::STUDENT, $user);
242+
}
233243

234244
return $this;
235245
}
@@ -242,6 +252,18 @@ public function removeUsers(UsergroupRelUser $user): void
242252
}
243253
}
244254

255+
public function addUser(User $user, int $relationType = 0): static
256+
{
257+
$rel = (new UsergroupRelUser())
258+
->setUser($user)
259+
->setRelationType($relationType)
260+
;
261+
262+
$this->addUsers($rel);
263+
264+
return $this;
265+
}
266+
245267
/**
246268
* Get id.
247269
*/

src/CoreBundle/Helpers/AzureAuthenticatorHelper.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
public const EXTRA_FIELD_AZURE_ID = 'azure_id';
2323
public const EXTRA_FIELD_AZURE_UID = 'azure_uid';
2424

25-
public const QUERY_FIELDS = [
25+
public const QUERY_USER_FIELDS = [
2626
'givenName',
2727
'surname',
2828
'mail',
@@ -33,6 +33,17 @@
3333
'mailNickname',
3434
'id',
3535
];
36+
public const QUERY_GROUP_FIELDS = [
37+
'id',
38+
'displayName',
39+
'description',
40+
];
41+
42+
public const QUERY_GROUP_MEMBERS_FIELDS = [
43+
'mail',
44+
'mailNickname',
45+
'id',
46+
];
3647

3748
public function __construct(
3849
private ExtraFieldValuesRepository $extraFieldValuesRepo,

src/CoreBundle/Repository/Node/UsergroupRepository.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
namespace Chamilo\CoreBundle\Repository\Node;
88

9+
use Chamilo\CoreBundle\Entity\AccessUrl;
910
use Chamilo\CoreBundle\Entity\Course;
1011
use Chamilo\CoreBundle\Entity\Session;
1112
use Chamilo\CoreBundle\Entity\User;
@@ -472,4 +473,23 @@ public function getCurrentAccessUrlId(): int
472473
// For now, returning 1 as a default value.
473474
return 1;
474475
}
476+
477+
public function getOneByTitleInUrl(string $title, AccessUrl $url): ?Usergroup
478+
{
479+
$qb = $this->getOrCreateQueryBuilder(null, 'g');
480+
481+
return $qb
482+
->innerJoin('g.urls', 'u')
483+
->where($qb->expr()->eq('g.title', ':title'))
484+
->andWhere($qb->expr()->eq('u.url', ':url'))
485+
->setMaxResults(1)
486+
->setParameters([
487+
'title' => $title,
488+
'url' => $url->getId()
489+
])
490+
->setParameter('title', $title)
491+
->getQuery()
492+
->getOneOrNullResult()
493+
;
494+
}
475495
}

0 commit comments

Comments
 (0)