Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions components/UserGroupAdminMethods.php
Original file line number Diff line number Diff line change
Expand Up @@ -665,4 +665,128 @@ public function addUsersByEmail(): void
}
}
}

/**
* @param resource $fp
* @param array<string, int> $headerMap
* @return array{processedRows: int, errors: string[]}
*/
public function processCsvChunk($fp, array $headerMap, string $collisionBehavior, bool $sendEmail, string $emailText): array
{
$processedRows = 0;
$errors = [];
$maxRowsPerChunk = 50;

while ($processedRows < $maxRowsPerChunk && ($row = fgetcsv($fp)) !== false) {
if (empty(array_filter($row))) {
continue; // Skip empty rows
}

$processedRows++;

try {
$email = isset($headerMap['email'], $row[$headerMap['email']]) ? trim($row[$headerMap['email']]) : '';
if ($email === '') {
$errors[] = 'Missing email on row.';
continue;
}

$firstName = isset($headerMap['first_name'], $row[$headerMap['first_name']]) ? trim($row[$headerMap['first_name']]) : '';
$lastName = isset($headerMap['last_name'], $row[$headerMap['last_name']]) ? trim($row[$headerMap['last_name']]) : '';
$organization = isset($headerMap['organization'], $row[$headerMap['organization']]) ? trim($row[$headerMap['organization']]) : '';

$name = trim($firstName . ' ' . $lastName);
if ($name === '') {
$name = $email;
}

/** @var \app\models\db\ConsultationUserGroup[] $userGroups */
$userGroups = [];
if (isset($headerMap['groups']) && !empty($row[$headerMap['groups']])) {
$groupNames = array_map('trim', explode(',', $row[$headerMap['groups']]));
foreach ($groupNames as $groupName) {
if ($groupName === '') continue;
$group = \app\models\db\ConsultationUserGroup::find()
->where(['title' => $groupName])
->orWhere(['external_id' => $groupName])
->one();
if ($group) {
$userGroups[] = $group;
} else {
$errors[] = "$email: Warning: Group '$groupName' not found.";
}
}
}
if (empty($userGroups)) {
$defaultGroup = $this->getDefaultUserGroup();
if ($defaultGroup !== null) {
$userGroups[] = $defaultGroup;
}
}

$user = \app\models\db\User::findOne(['email' => $email]);

if ($user) {
if ($collisionBehavior === 'skip') {
continue;
}

if ($firstName !== '' || $lastName !== '') {
$user->nameGiven = $firstName;
$user->nameFamily = $lastName;
$user->name = $name;
}
if ($organization !== '') {
$user->organization = $organization;
}
$user->save(false);

if ($collisionBehavior === 'replace') {
$user->unlinkAll('userGroups', true);
foreach ($userGroups as $group) {
$user->link('userGroups', $group);
$this->logUserGroupAdd($user, $group);
}
} elseif ($collisionBehavior === 'merge') {
$existingGroupIds = array_map(fn($g) => $g->id, $user->userGroups);
foreach ($userGroups as $group) {
if (!in_array($group->id, $existingGroupIds)) {
$user->link('userGroups', $group);
$this->logUserGroupAdd($user, $group);
}
}
}
} else {
$auth = \app\models\db\User::AUTH_EMAIL . ':' . mb_strtolower($email);
$user = new \app\models\db\User();
$user->auth = $auth;
$user->email = mb_strtolower($email);
$user->name = $name;
if ($firstName !== '') $user->nameGiven = $firstName;
if ($lastName !== '') $user->nameFamily = $lastName;
if ($organization !== '') $user->organization = $organization;
$user->pwdEnc = password_hash(\app\models\db\User::createPassword(), PASSWORD_DEFAULT);
$user->status = \app\models\db\User::STATUS_CONFIRMED;
$user->emailConfirmed = 1;
$user->organizationIds = '';
$user->save(false);

foreach ($userGroups as $group) {
$user->link('userGroups', $group);
$this->logUserGroupAdd($user, $group);
}
if ($sendEmail) {
$this->sendWelcomeEmail($user, $emailText, null);
}
}
} catch (\Exception $e) {
$errors[] = $email . ': ' . $e->getMessage();
}
}

return [
'processedRows' => $processedRows,
'errors' => $errors
];
}
}
90 changes: 90 additions & 0 deletions controllers/admin/UsersController.php
Original file line number Diff line number Diff line change
Expand Up @@ -309,4 +309,94 @@ public function actionPoll(): RestApiResponse

return new RestApiResponse(200, $this->getUsersWidgetData($consultation));
}
public function actionUploadCsvInit(): JsonResponse
{
$this->getConsultationAndCheckAdminPermission();

if (empty($_FILES['csvFile']['tmp_name'])) {
return new JsonResponse(['success' => false, 'error' => 'No file uploaded.']);
}

$tmpName = $_FILES['csvFile']['tmp_name'];
$token = uniqid('csv_', true);
$filePath = \Yii::$app->getRuntimePath() . '/' . $token . '.csv';
Comment thread
niStee marked this conversation as resolved.
Outdated

if (!move_uploaded_file($tmpName, $filePath)) {
return new JsonResponse(['success' => false, 'error' => 'Failed to save uploaded file.']);
}

$size = filesize($filePath);

return new JsonResponse([
'success' => true,
'token' => $token,
'totalSize' => $size,
'startOffset' => 0
]);
}

public function actionProcessCsvChunk(): JsonResponse
{
$this->getConsultationAndCheckAdminPermission();

$token = $this->getPostValue('token', '');
$offset = (int) $this->getPostValue('offset', 0);
$collisionBehavior = $this->getPostValue('collisionBehavior', 'skip');
$sendEmail = (bool) $this->getPostValue('sendEmail', false);
$emailText = $this->getPostValue('emailText', '');

if (!$token || !preg_match('/^csv_[a-zA-Z0-9.]+$/', $token)) {
return new JsonResponse(['success' => false, 'error' => 'Invalid token.']);
}

$filePath = \Yii::$app->getRuntimePath() . '/' . $token . '.csv';
if (!file_exists($filePath)) {
return new JsonResponse(['success' => false, 'error' => 'File not found or expired.']);
}

$fp = fopen($filePath, 'r');
if ($fp === false) {
return new JsonResponse(['success' => false, 'error' => 'Could not open file.']);
}

// Always read the header first to establish mappings
$header = fgetcsv($fp);
if ($header === false) {
return new JsonResponse(['success' => false, 'error' => 'File is empty.']);
}

// Strip BOM from first column name if present
$header[0] = preg_replace('/^\xEF\xBB\xBF/', '', (string) $header[0]);
$headerMap = array_flip(array_map('trim', array_map(function($v) { return strtolower((string) $v); }, $header)));

if (!isset($headerMap['email'])) {
fclose($fp);
@unlink($filePath);
return new JsonResponse(['success' => false, 'error' => 'CSV is missing the required "email" column.']);
}

if ($offset === 0) {
$offset = ftell($fp);
Comment thread
niStee marked this conversation as resolved.
Outdated
} else {
fseek($fp, $offset);
}

$result = $this->userGroupAdminMethods->processCsvChunk($fp, $headerMap, $collisionBehavior, $sendEmail, $emailText);
$nextOffset = ftell($fp);
$finished = feof($fp) || $result['processedRows'] === 0;

fclose($fp);

if ($finished) {
@unlink($filePath);
}

return new JsonResponse([
'success' => true,
'nextOffset' => $nextOffset,
'processed' => $result['processedRows'],
'errors' => $result['errors'],
'finished' => $finished
]);
}
}
4 changes: 4 additions & 0 deletions docs/sample_user_import.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
email,first_name,last_name,organization,groups
ada.lovelace@example.com,Ada,Lovelace,Volt Europa,"Delegates"
alan.turing@example.com,Alan,Turing,,
grace.hopper@example.com,Grace,Hopper,Volt Germany,"Admin, Delegates"
Comment thread
niStee marked this conversation as resolved.
40 changes: 40 additions & 0 deletions messages/en/admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -1084,4 +1084,44 @@
'text' => 'Do you want to enable the update mode? While the update mode is active, this installation of Antragsgrün will be unavailable for all users.',
'js' => true,
],
'csv_uploading' => [
'text' => 'Uploading file...',
'js' => true,
],
'csv_init_failed' => [
'text' => 'Failed to initialize upload',
'js' => true,
],
'csv_processing' => [
'text' => 'Processing... ({percent}%)',
'js' => true,
],
'csv_chunk_failed' => [
'text' => 'Failed to process chunk',
'js' => true,
],
'csv_success' => [
'text' => 'Successfully processed {processedRows} users.',
'js' => true,
],
'csv_errors' => [
'text' => '<strong>Errors encountered:</strong><br>',
'js' => true,
],
'csv_errors_count' => [
'text' => ' Encountered {errorCount} errors.',
'js' => true,
],
'csv_reload' => [
'text' => ' You can reload the page to see them.',
'js' => true,
],
'csv_error_prefix' => [
'text' => 'Error: {message}',
'js' => true,
],
'csv_failed' => [
'text' => 'Import failed.',
'js' => true,
],
];
53 changes: 53 additions & 0 deletions views/admin/users/_users_add_accounts.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@
?>
<script type="module">
import { UserAdminCreate } from "/js/modules/backend/UserAdminCreate.js";
import { UserAdminCsvImport } from "/js/modules/backend/UserAdminCsvImport.js";
new UserAdminCreate(document.getElementById("accountsCreateForm"));
new UserAdminCsvImport(document.getElementById("accountsCreateForm"));
Comment thread
niStee marked this conversation as resolved.
Outdated
</script>
<section id="accountsCreateForm" class="adminForm form-horizontal accountsCreateForm"
data-organisations="<?= Html::encode(json_encode($consultation->getSettings()->organisations)) ?>"
Expand Down Expand Up @@ -179,6 +181,9 @@
<button class="btn btn-link addUsersOpener email" type="button" data-type="email">
<?= Yii::t('admin', 'siteacc_add_email_btn') ?>
</button>
<button class="btn btn-link addUsersOpener csv" type="button" data-type="csv">
CSV Import
</button>
<?php
foreach ($addMultipleForms as $authId => $form) {
if (!$form) {
Expand Down Expand Up @@ -266,5 +271,53 @@
}

?>

<form class="addUsersByLogin multiuser csv hidden" enctype="multipart/form-data" id="csvImportForm">
<div class="alert alert-info">
Upload a CSV file with the following columns: <code>email, first_name, last_name, organization, groups</code>.
Comment thread
niStee marked this conversation as resolved.
Outdated
(Only <code>email</code> is strictly required. <code>groups</code> can be a comma-separated list of group names or external IDs).
</div>
<div class="stdTwoCols">
<label class="leftColumn">CSV File:</label>
<div class="rightColumn">
<input type="file" class="form-control" name="csvFile" accept=".csv" required>
</div>
</div>
<div class="stdTwoCols">
<label class="leftColumn">Existing Users:</label>
<div class="rightColumn">
<select class="form-control" name="collisionBehavior">
<option value="skip">Skip existing users</option>
<option value="merge">Update user and merge new groups</option>
<option value="replace">Update user and replace groups</option>
</select>
</div>
</div>
<?php if ($hasEmail) { ?>
<div class="stdTwoCols">
<label class="leftColumn">Welcome Email:</label>
<div class="rightColumn">
<label>
<input type="checkbox" name="sendEmail" id="csvSendEmail" value="1">
Send welcome email to NEW users
</label>
<textarea id="csvEmailText" class="form-control hidden" name="emailText" rows="11"><?= Html::encode($preText) ?></textarea>
</div>
</div>
<?php } ?>
<div class="saveholder">
<button type="submit" class="btn btn-primary" id="csvSubmitBtn">Upload and Process CSV</button>
</div>

<div id="csvProgressContainer" class="hidden" style="margin-top: 20px;">
<p id="csvProgressText">Processing...</p>
<div class="progress">
<div id="csvProgressBar" class="progress-bar progress-bar-striped active" role="progressbar" style="width: 0%"></div>
</div>
<div id="csvErrorLog" class="alert alert-danger hidden" style="max-height: 200px; overflow-y: auto;"></div>
</div>
</form>

<?php
Comment thread
niStee marked this conversation as resolved.
Outdated
</div>
</section>
Loading
Loading