From 5aac685afd0e309d25e56219397c19a345d4e19c Mon Sep 17 00:00:00 2001 From: Ben A <195394081+swebena@users.noreply.github.com> Date: Tue, 8 Apr 2025 16:24:22 +0000 Subject: [PATCH] [feature] csv import add notes and user_groups field #396 Batch csv import now accepts notes and setting RADIUS groups for each user. The previous format is still accepted for backwards compatibility. Fixes #396 --- docs/user/importing_users.rst | 12 ++++++++++++ openwisp_radius/base/models.py | 32 ++++++++++++++++++++++++++++---- openwisp_radius/utils.py | 18 ++++++++++++++++++ 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/docs/user/importing_users.rst b/docs/user/importing_users.rst index d8252337..9160b9da 100644 --- a/docs/user/importing_users.rst +++ b/docs/user/importing_users.rst @@ -14,6 +14,7 @@ many features included in it such as: Usernames are generated from the email address whereas passwords are generated randomly and their lengths can be customized. - Passwords are accepted in both clear-text and hash formats from the CSV. +- Set the RADIUS user groups that the user will belong to. - Send mails to users whose passwords have been generated automatically. This operation can be performed via the admin interface, with a management @@ -26,10 +27,21 @@ CSV Format The CSV shall be of the format: +:: + + username,password,email,firstname,lastname,notes,user_groups + +`user_groups` consists of one or more radius group names separated by a semicolon. +Inserting groups that don't exist will silently fail. + +The previous format is also supported for backwards compatibility: + :: username,password,email,firstname,lastname +OpenWISP will recognize the correct format automatically. + Imported users with hashed passwords ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/openwisp_radius/base/models.py b/openwisp_radius/base/models.py index fd7b04ff..0e297cac 100644 --- a/openwisp_radius/base/models.py +++ b/openwisp_radius/base/models.py @@ -925,18 +925,36 @@ def clean(self): super().clean() def add(self, reader, password_length=BATCH_DEFAULT_PASSWORD_LENGTH): + RadiusUserGroup = swapper.load_model('openwisp_radius', 'RadiusUserGroup') + RadiusGroup = swapper.load_model('openwisp_radius', 'RadiusGroup') users_list = [] generated_passwords = [] + user_group_associations = [] + for row in reader: - if len(row) == 5: + # Both formats + if len(row) in [5, 7]: user, password = self.get_or_create_user( row, users_list, password_length ) users_list.append(user) if password: generated_passwords.append(password) + # New format + if len(row) == 7: + radius_ugroups = row[6] + groupnames = radius_ugroups.split(';') + for groupname in groupnames: + user_group_associations.append((user, groupname.strip())) for user in users_list: self.save_user(user) + for user, groupname in user_group_associations: + if RadiusGroup.objects.filter(name=groupname).exists(): + RadiusUserGroup.objects.get_or_create( + user=user, + groupname=groupname, + defaults={'priority': 1} + ) for element in generated_passwords: username, password, user_email = element send_mail( @@ -969,17 +987,17 @@ def prefix_add(self, prefix, n, password_length=BATCH_DEFAULT_PASSWORD_LENGTH): def get_or_create_user(self, row, users_list, password_length): User = get_user_model() - username, password, email, first_name, last_name = row + username, password, email, first_name, last_name, notes, radius_ugroups_string = self._batch_csv_read_row(row) if email and User.objects.filter(email=email).exists(): user = User.objects.get(email=email) return user, None generated_password = None - username, password, email, first_name, last_name = row + username, password, email, first_name, last_name, notes, radius_ugroups_string = self._batch_csv_read_row(row) if not username and email: username = email.split('@')[0] username = find_available_username(username, users_list) user = User( - username=username, email=email, first_name=first_name, last_name=last_name + username=username, email=email, first_name=first_name, last_name=last_name, notes=notes ) cleartext_delimiter = 'cleartext$' if not password: @@ -1024,6 +1042,12 @@ def expire(self): u.is_active = False u.save() + def _batch_csv_read_row(self, row): + # 5 or 7 fields, for backwards compatibility with previous CSV format. + read_row = lambda row: (*row[:5], *row[5:7], '', '')[:7] + username, password, email, first_name, last_name, notes, radius_ugroups_string = readrow(row) + return username, password, email, first_name, last_name, notes, radius_ugroups_string + def _remove_files(self): if self.csvfile: self.csvfile.storage.delete(self.csvfile.name) diff --git a/openwisp_radius/utils.py b/openwisp_radius/utils.py index 1e79df9a..d7c30b22 100644 --- a/openwisp_radius/utils.py +++ b/openwisp_radius/utils.py @@ -150,6 +150,14 @@ def decode_byte_data(data): return data +def validate_csv_batch_field_radusergroups(rugs_string): + if not rugs_string.strip(): + return [] + reader = csv.reader([rugs_string], delimiter=' ') + rugs = next(reader) + return rugs + + def validate_csvfile(csvfile): csv_data = csvfile.read() @@ -177,6 +185,16 @@ def validate_csvfile(csvfile): _(error_message.format(str(row_count), error.message)) ) row_count += 1 + elif len(row) == 7: + username, password, email, firstname, lastname, notes, link_radius_usergroups = row + try: + validate_csv_batch_field_radusergroups(link_radius_usergroups) + validate_email(email) + except ValidationError as error: + raise ValidationError( + _(error_message.format(str(row_count), error.message)) + ) + row_count += 1 elif len(row) > 0: raise ValidationError( _(error_message.format(str(row_count), 'Improper CSV format.'))