Skip to content

Commit 6d6f928

Browse files
authored
Merge pull request #6503 from christianbeeznest/ras-22793
Admin: Add bulk user assignment and removal from URLs via CSV import - refs BT#22793
2 parents 1775c7b + 9e2fe41 commit 6d6f928

File tree

6 files changed

+274
-0
lines changed

6 files changed

+274
-0
lines changed

public/main/admin/access_url_edit_users_to_url.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
use Chamilo\CoreBundle\Enums\ActionIcon;
10+
use Chamilo\CoreBundle\Framework\Container;
1011

1112
$cidReset = true;
1213

@@ -141,6 +142,18 @@ function remove_item(origin) {
141142
Display::getMdiIcon(ActionIcon::MULTI_COURSE_URL_ASSIGN, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Add user to this URL')),
142143
api_get_path(WEB_CODE_PATH).'admin/access_url_add_users_to_url.php'
143144
);
145+
146+
$urlAddCsv = Container::getRouter()->generate('chamilo_core_access_url_users_import');
147+
$urlRemoveCsv = Container::getRouter()->generate('chamilo_core_access_url_users_remove');
148+
echo Display::url(
149+
Display::getMdiIcon(ActionIcon::IMPORT_USERS_TO_URL, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Assign users from CSV')),
150+
$urlAddCsv
151+
);
152+
153+
echo Display::url(
154+
Display::getMdiIcon(ActionIcon::REMOVE_USERS_FROM_URL, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Remove users from CSV')),
155+
$urlRemoveCsv
156+
);
144157
echo '</div>';
145158

146159

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/* For licensing terms, see /license.txt */
6+
7+
namespace Chamilo\CoreBundle\Controller;
8+
9+
use Chamilo\CoreBundle\Entity\User;
10+
use Chamilo\CoreBundle\Entity\AccessUrl;
11+
use Doctrine\ORM\EntityManagerInterface;
12+
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
13+
use Symfony\Component\HttpFoundation\Request;
14+
use Symfony\Component\HttpFoundation\Response;
15+
use Symfony\Component\Routing\Annotation\Route;
16+
use Symfony\Component\Security\Http\Attribute\IsGranted;
17+
use Symfony\Contracts\Translation\TranslatorInterface;
18+
19+
#[Route('/access-url')]
20+
class AccessUrlController extends AbstractController
21+
{
22+
public function __construct(
23+
private readonly TranslatorInterface $translator,
24+
private readonly EntityManagerInterface $em,
25+
) {}
26+
27+
#[IsGranted('ROLE_ADMIN')]
28+
#[Route('/users/import', name: 'chamilo_core_access_url_users_import', methods: ['GET', 'POST'])]
29+
public function importUsers(Request $request): Response
30+
{
31+
$report = [];
32+
33+
if ($request->isMethod('POST') && $request->files->has('csv_file')) {
34+
$file = $request->files->get('csv_file')->getPathname();
35+
$handle = fopen($file, 'r');
36+
$lineNumber = 0;
37+
38+
while (($data = fgetcsv($handle, 1000, ',')) !== false) {
39+
$lineNumber++;
40+
41+
if ($lineNumber === 1 && strtolower(trim($data[0])) === 'username') {
42+
continue; // Skip header
43+
}
44+
45+
if (count($data) < 2) {
46+
$report[] = $this->formatReport('alert-circle', 'Line %s: invalid format. Two columns expected.', [$lineNumber]);
47+
continue;
48+
}
49+
50+
[$username, $url] = array_map('trim', $data);
51+
52+
if (!$username || !$url) {
53+
$report[] = $this->formatReport('alert-circle', 'Line %s: missing username or URL.', [$lineNumber]);
54+
continue;
55+
}
56+
57+
// Normalize URL
58+
if (!str_starts_with($url, 'http')) {
59+
$url = 'https://' . $url;
60+
}
61+
if (!str_ends_with($url, '/')) {
62+
$url .= '/';
63+
}
64+
65+
$user = $this->em->getRepository(User::class)->findOneBy(['username' => $username]);
66+
if (!$user) {
67+
$report[] = $this->formatReport('close-circle', "Line %s: user '%s' not found.", [$lineNumber, $username]);
68+
continue;
69+
}
70+
71+
$accessUrl = $this->em->getRepository(AccessUrl::class)->findOneBy(['url' => $url]);
72+
if (!$accessUrl) {
73+
$report[] = $this->formatReport('close-circle', "Line %s: URL '%s' not found.", [$lineNumber, $url]);
74+
continue;
75+
}
76+
77+
if ($accessUrl->hasUser($user)) {
78+
$report[] = $this->formatReport('information-outline', "Line %s: user '%s' is already assigned to '%s'.", [$lineNumber, $username, $url]);
79+
} else {
80+
$accessUrl->addUser($user);
81+
$this->em->persist($accessUrl);
82+
$report[] = $this->formatReport('check-circle', "Line %s: user '%s' successfully assigned to '%s'.", [$lineNumber, $username, $url]);
83+
}
84+
}
85+
86+
fclose($handle);
87+
$this->em->flush();
88+
}
89+
90+
return $this->render('@ChamiloCore/AccessUrl/import_users.html.twig', [
91+
'report' => $report,
92+
'title' => $this->translator->trans('Assign users to URLs from CSV'),
93+
]);
94+
}
95+
96+
#[IsGranted('ROLE_ADMIN')]
97+
#[Route('/users/remove', name: 'chamilo_core_access_url_users_remove', methods: ['GET', 'POST'])]
98+
public function removeUsers(Request $request): Response
99+
{
100+
$report = [];
101+
102+
if ($request->isMethod('POST') && $request->files->has('csv_file')) {
103+
$file = $request->files->get('csv_file')->getPathname();
104+
$handle = fopen($file, 'r');
105+
$lineNumber = 0;
106+
107+
while (($data = fgetcsv($handle, 1000, ',')) !== false) {
108+
$lineNumber++;
109+
110+
if ($lineNumber === 1 && strtolower(trim($data[0])) === 'username') {
111+
continue; // Skip header
112+
}
113+
114+
[$username, $url] = array_map('trim', $data);
115+
116+
if (!$username || !$url) {
117+
$report[] = $this->formatReport('alert-circle', 'Line %s: empty fields.', [$lineNumber]);
118+
continue;
119+
}
120+
121+
if (!str_starts_with($url, 'http')) {
122+
$url = 'https://' . $url;
123+
}
124+
if (!str_ends_with($url, '/')) {
125+
$url .= '/';
126+
}
127+
128+
$user = $this->em->getRepository(User::class)->findOneBy(['username' => $username]);
129+
if (!$user) {
130+
$report[] = $this->formatReport('close-circle', "Line %s: user '%s' not found.", [$lineNumber, $username]);
131+
continue;
132+
}
133+
134+
$accessUrl = $this->em->getRepository(AccessUrl::class)->findOneBy(['url' => $url]);
135+
if (!$accessUrl) {
136+
$report[] = $this->formatReport('close-circle', "Line %s: URL '%s' not found.", [$lineNumber, $url]);
137+
continue;
138+
}
139+
140+
foreach ($accessUrl->getUsers() as $rel) {
141+
if ($rel->getUser()->getId() === $user->getId()) {
142+
$this->em->remove($rel);
143+
$report[] = $this->formatReport('account-remove-outline', "Line %s: user '%s' removed from '%s'.", [$lineNumber, $username, $url]);
144+
continue 2;
145+
}
146+
}
147+
148+
$report[] = $this->formatReport('alert-circle', 'Line %s: no relation found between user and URL.', [$lineNumber]);
149+
}
150+
151+
fclose($handle);
152+
$this->em->flush();
153+
}
154+
155+
return $this->render('@ChamiloCore/AccessUrl/remove_users.html.twig', [
156+
'report' => $report,
157+
'title' => $this->translator->trans('Remove users from URLs from CSV')
158+
]);
159+
}
160+
161+
private function formatReport(string $icon, string $message, array $params): string
162+
{
163+
$text = vsprintf($this->translator->trans($message), $params);
164+
return sprintf('<i class="mdi mdi-%s text-base me-1"></i> %s', $icon, $text);
165+
}
166+
}

src/CoreBundle/Enums/ActionIcon.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,10 @@ enum ActionIcon: string
162162
case EXIT = 'exit-run';
163163
// Edit badges/skills
164164
case EDIT_BADGE = 'shield-edit-outline';
165+
// Import users from CSV to Access URL
166+
case IMPORT_USERS_TO_URL = 'file-import';
167+
// Remove users from Access URL using CSV
168+
case REMOVE_USERS_FROM_URL = 'file-remove';
165169

166170
case ADD_EVENT_REMINDER = 'alarm-plus';
167171

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<form method="post" enctype="multipart/form-data" class="mt-4 space-y-6 max-w-xl">
2+
<div>
3+
<label for="csv_file" class="block text-sm font-medium text-gray-700">
4+
{{ 'Select CSV file'|trans }}
5+
</label>
6+
<input type="file" name="csv_file" id="csv_file" accept=".csv"
7+
class="mt-1 block w-full rounded-md border border-gray-300 p-2 text-sm shadow-sm"
8+
required />
9+
</div>
10+
11+
{% if confirmMessage is defined %}
12+
<script>
13+
document.addEventListener('DOMContentLoaded', function () {
14+
const form = document.querySelector('form');
15+
form.addEventListener('submit', function (e) {
16+
if (!confirm("{{ confirmMessage|e('js') }}")) {
17+
e.preventDefault();
18+
}
19+
});
20+
});
21+
</script>
22+
{% endif %}
23+
24+
<div>
25+
<button type="submit" class="btn btn--primary">
26+
{{ submitLabel|default('Submit'|trans) }}
27+
</button>
28+
</div>
29+
</form>
30+
31+
{% if report is defined and report is not empty %}
32+
<div class="mt-8 border border-gray-300 rounded-md p-4 bg-gray-10">
33+
<h3 class="text-md font-semibold mb-3">{{ 'Import report'|trans }}</h3>
34+
<ul class="list-none pl-0 text-sm text-gray-700 space-y-2">
35+
{% for line in report %}
36+
<li class="flex items-start gap-2">
37+
{{ line|raw }}
38+
</li>
39+
{% endfor %}
40+
</ul>
41+
</div>
42+
{% endif %}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{% extends "@ChamiloCore/Layout/layout_one_col.html.twig" %}
2+
3+
{% block content %}
4+
<div class="mb-6">
5+
<a href="{{ url('legacy_main', { 'name' : 'admin/access_url_edit_users_to_url.php' }) }}"
6+
class="inline-flex items-center gap-2 text-sm text-primary hover:underline">
7+
<i class="mdi mdi-arrow-left"></i>
8+
{{ 'Back to user assignment page'|trans }}
9+
</a>
10+
</div>
11+
12+
<div class="section-header section-header--h2 mb-4">
13+
<h2 class="section-header__title">{{ title }}</h2>
14+
</div>
15+
16+
<p class="text-sm text-gray-700 mb-4 max-w-3xl">
17+
{{ 'Upload a CSV file with two columns: username and url. Each url must start with https:// and end with /. Existing assignments will not be removed.'|trans }}
18+
</p>
19+
20+
{% include "@ChamiloCore/AccessUrl/_import_form.twig" with {
21+
'report': report,
22+
'submitLabel': 'Assign users'|trans
23+
} %}
24+
{% endblock %}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{% extends "@ChamiloCore/Layout/layout_one_col.html.twig" %}
2+
3+
{% block content %}
4+
<div class="mb-6">
5+
<a href="{{ url('legacy_main', { 'name' : 'admin/access_url_edit_users_to_url.php' }) }}"
6+
class="inline-flex items-center gap-2 text-sm text-primary hover:underline">
7+
<i class="mdi mdi-arrow-left"></i>
8+
{{ 'Back to user assignment page'|trans }}
9+
</a>
10+
</div>
11+
12+
<div class="section-header section-header--h2 mb-4">
13+
<h2 class="section-header__title">{{ title }}</h2>
14+
</div>
15+
16+
<p class="text-sm text-gray-700 mb-4 max-w-3xl">
17+
{{ 'Upload a CSV file with two columns: username and url. Each url must start with https:// and end with /. Only existing assignments will be removed.'|trans }}
18+
</p>
19+
20+
{% include "@ChamiloCore/AccessUrl/_import_form.twig" with {
21+
'report': report,
22+
'submitLabel': 'Remove users'|trans,
23+
'confirmMessage': 'Are you sure you want to remove users from URLs in batch?'|trans
24+
} %}
25+
{% endblock %}

0 commit comments

Comments
 (0)