Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
# Release Notes
## [0.9.3] - Jul 28, 2026
- [Added] Import groups by name via **Assign to Group** and **Unassign from Group** CSV fields
- [Docs] Expanded README with usage guide and package design notes

## [0.9.2] - Dec 6, 2024
- [Added] Ability to configure export batch size in `concrete.export.csv.batch_size` config

Expand Down
98 changes: 92 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,102 @@
# Concrete CMS add-on: Macareux CSV User Import & Export

This package enables it to export users as CSV files and import users from CSV files.
Import users from CSV files and export users as CSV files, with selectable columns.

Concrete Core has a feature to export users, but you can select columns to export with this package.
Concrete Core can export users, but this package lets you choose which columns to include. Import maps CSV columns to user properties, groups, and attributes.

This package supports Concrete CMS version 9+ only.
You can use the [legacy version](https://github.com/MacareuxDigital/csv_user_import_export) if you're running Concrete CMS version 8.
**Requirements:** Concrete CMS 9+. For version 8, use the [legacy package](https://github.com/MacareuxDigital/csv_user_import_export).

## Installation

1. Place the package under `packages/md_csv_user_import_export`.
2. Install (or update) it from **Dashboard → Extend Concrete**.
3. Dashboard pages appear under **Users → Import** and **Users → Export**.

## How to use

### Export

1. Open **Dashboard → Users → Export**.
2. Check the columns to include (core fields, groups, attributes).
3. Optionally enable **Keep mapping** so the next visit remembers your selection.
4. Download the CSV.

Group columns export as `1` (member) or `0` (not a member). Attribute columns use each attribute’s text representation.

### Import

1. Upload a CSV (header row required) into the File Manager.
2. Open **Dashboard → Users → Import** and select that file.
3. On the mapping screen, for each importable property, choose the matching CSV column (or **Ignore**).
4. Optionally enable **Keep mapping** and/or **Delete CSV file after imported**.
5. Run **Import**. Rows are processed in a progressive batch.

Matching users: if a row has a **Username**, the user is looked up by name; otherwise by **Email**. Existing users are updated; missing users are created. Empty mapped cells leave the current value alone.

### Group import

Two modes (can be used together):

| Mode | Mapping | Cell value | Effect |
|------|---------|------------|--------|
| Per-group column | Property = group display name (e.g. Administrators) | `1` or `0` | Join or leave that group |
| By group name | Property = **Assign to Group** or **Unassign from Group** | Exact group name (e.g. `Administrators`) | Join or leave that group |

Rules for name-based assign/unassign:

- One group name per cell only (no comma-separated lists).
- Empty or ignored → membership unchanged.
- Unknown group name → logged and skipped; the rest of the row still imports.

Example CSV for name-based assign:

```csv
Username,Email,Group
alice,alice@example.com,Administrators
bob,bob@example.com,Editors
```

Map `Group` → **Assign to Group**.

Example CSV for per-group columns:

```csv
Username,Email,Administrators,Editors
alice,alice@example.com,1,0
bob,bob@example.com,0,1
```

Map each group property to the matching column.

## Package design

### Architecture

| Piece | Role |
|-------|------|
| `CsvTrait` | Shared list of exportable/importable headers (static fields, `g:{id}` groups, `a:{handle}` attributes) |
| Import controller | Upload → mapping UI → batch of `ImportUserCommand` per row |
| `ImportUserCommandHandler` | Create/update user, then apply groups and attributes |
| Export controller + `Exporter` | Selected columns → streamed CSV download |

Saved column choices live in package file config (`csv.import_columns`, `csv.export_columns`) when **Keep mapping** is on.

### Why two group import modes?

- **Per-group boolean columns** match export output and suit spreadsheets that already have one column per group. Internal handles are `g:{groupID}` so renames of display labels do not break stored mappings keyed by ID.
- **Assign / Unassign by name** suits CSVs with a single “Group” (or similar) column whose *value* is the group name. Export does not emit these fields: one cell cannot represent membership in multiple groups, so export keeps the boolean columns only.

### Other decisions

- **Empty cell = no change** for groups and most fields, so partial CSVs do not wipe data.
- **Attributes** use Concrete’s text-export interfaces (`SimpleTextExportableAttributeInterface` / `MulticolumnTextExportableAttributeInterface`), same idea as core CSV tooling.
- **Missing assign/unassign groups** do not fail the row; they are logged so bulk imports stay resilient.
- **Home folder** import/export is not implemented yet (see ToDo).

## ToDo

Support export/import home folder
- Support export/import home folder

## License

MIT License.
MIT License.
2 changes: 1 addition & 1 deletion controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class Controller extends Package
/**
* @var string package version
*/
protected $pkgVersion = '0.9.2';
protected $pkgVersion = '0.9.3';

/**
* {@inheritdoc}
Expand Down
2 changes: 2 additions & 0 deletions src/Traits/CsvTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ protected function getImportableHeaders(bool $includeSubHeaders = true): array
'active' => t('Active'),
'validated' => t('Validated'),
'password' => t('Password'),
'assign_group' => t('Assign to Group'),
'unassign_group' => t('Unassign from Group'),
];

$groupHeaders = $this->getGroupHeaders();
Expand Down
18 changes: 18 additions & 0 deletions src/User/Command/ImportUserCommandHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Concrete\Core\Error\ErrorList\ErrorList;
use Concrete\Core\Logging\LoggerFactory;
use Concrete\Core\Support\Facade\Application;
use Concrete\Core\User\Group\Group;
use Concrete\Core\User\RegistrationService;
use Concrete\Core\User\UserInfoRepository;
use Concrete\Core\Utility\Service\Identifier;
Expand Down Expand Up @@ -144,6 +145,23 @@ public function __invoke(ImportUserCommand $command)
}
}

foreach (['assign_group' => true, 'unassign_group' => false] as $handle => $assign) {
$groupName = $this->getValue($row, $handle);
if ($groupName === null) {
continue;
}
$group = Group::getByName($groupName);
if ($group) {
if ($assign) {
$user->enterGroup($group);
} else {
$user->exitGroup($group);
}
} else {
$logger->info(t('Group not found: %s (row %s)', $groupName, $line));
}
}

/** @var ErrorList $attributesWarnings */
$attributesWarnings = $app->make(ErrorList::class);
foreach ($this->getAttributeKeys() as $keyHandle => $attributeKey) {
Expand Down